scrylab 0.1.0__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.
- scrylab-0.1.0/.gitignore +3 -0
- scrylab-0.1.0/LICENSE +21 -0
- scrylab-0.1.0/PKG-INFO +162 -0
- scrylab-0.1.0/README.md +133 -0
- scrylab-0.1.0/pyproject.toml +42 -0
- scrylab-0.1.0/src/scrylab/__init__.py +105 -0
- scrylab-0.1.0/src/scrylab/_client.py +127 -0
- scrylab-0.1.0/src/scrylab/_utils.py +62 -0
- scrylab-0.1.0/tests/__init__.py +0 -0
- scrylab-0.1.0/tests/test_utils.py +71 -0
scrylab-0.1.0/.gitignore
ADDED
scrylab-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ScryLab
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
scrylab-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scrylab
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the ScryLab GUI – send signals into a live ScryLab session from scripts, notebooks or simulation pipelines
|
|
5
|
+
Project-URL: Repository, https://github.com/scrylab/scrylab-python
|
|
6
|
+
Project-URL: Issues, https://github.com/scrylab/scrylab-python/issues
|
|
7
|
+
Author-email: ScryLab <support@scrylab.de>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: measurement,plot,scrylab,signal,time-series,visualization
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: numpy>=1.24
|
|
22
|
+
Requires-Dist: requests>=2.28
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pandas>=1.5; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
26
|
+
Provides-Extra: pandas
|
|
27
|
+
Requires-Dist: pandas>=1.5; extra == 'pandas'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# scrylab-python
|
|
31
|
+
|
|
32
|
+
Python wrapper for the [ScryLab](https://scrylab.de) REST API. Lets you send signals directly into a running ScryLab GUI from any Python environment – scripts, Jupyter notebooks or simulation pipelines – as a faster, more interactive alternative to matplotlib or plotly for exploratory signal analysis.
|
|
33
|
+
|
|
34
|
+
## What you can do
|
|
35
|
+
|
|
36
|
+
- Send 1D signals (time series, measurement channels, …) into ScryLab's data browser
|
|
37
|
+
- Send ~~colored lines (signal + scalar color axis)~~ (coming soon) or spectrograms (2D matrix)
|
|
38
|
+
- Convenience function: open signals directly in a plot from code
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
1. Prerequisite: the ScryLab GUI (desktop app) needs to be running on the same machine. [Installation Guide](https://docs.scrylab.de/docs/getting-started/installation/)
|
|
43
|
+
2. Install the wrapper: `pip install scrylab`
|
|
44
|
+
|
|
45
|
+
## Quick start
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
import scrylab as scry
|
|
49
|
+
import numpy as np
|
|
50
|
+
|
|
51
|
+
t = np.linspace(0, 10, 1_000)
|
|
52
|
+
y = np.sin(2 * np.pi * 5 * t)
|
|
53
|
+
|
|
54
|
+
scry.plot(y, name="Example Signal", x=t, y_unit="V", x_unit="s") # Convenience function: send + plot in one step
|
|
55
|
+
|
|
56
|
+
# Or send without plotting – then you can drag the signal into plots manually from the data browser
|
|
57
|
+
scry.send(y, name="Example Signal via send", x=t, y_unit="V", x_unit="s")
|
|
58
|
+
|
|
59
|
+
# Update the "Example Signal"
|
|
60
|
+
y2 = np.sin(2 * np.pi * 10 * t)
|
|
61
|
+
scry.send(y2, name="Example Signal", x=t, y_unit="V", x_unit="s", overwrite=True) # overwrite=True replaces the existing signal with the same name
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Usage
|
|
65
|
+
|
|
66
|
+
### `scry.send(y, …)`
|
|
67
|
+
|
|
68
|
+
Sends a signal into a data source without automatic plotting.
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
scry.send(y, name="Speed", source="Testrun 01", x=t, y_unit="km/h", x_unit="s")
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
| Parameter | Description |
|
|
75
|
+
|-----------|-------------|
|
|
76
|
+
| `y` | numpy array, list, or `pandas.Series`; for a Series, `name` and `x` default to the series' name and index |
|
|
77
|
+
| `name` | signal name (auto-generated if omitted) |
|
|
78
|
+
| `source` | [data source](https://docs.scrylab.de/docs/concepts/data-sources/) to send into, default `"Sent from API"`; created automatically if it doesn't exist yet |
|
|
79
|
+
| `x` | numpy array, list, or `pandas.Series`/`Index`; auto-generated if omitted |
|
|
80
|
+
| `z` | optional color axis – 1D array/list/`pandas.Series` (one value per sample, colors the trace) or 2D array/`pandas.DataFrame` (matrix → spectrogram; columns map to x-axis, index to y-axis) |
|
|
81
|
+
| `y_unit`, `x_unit`, `z_unit` | axis units, e.g. `"V"`, `"s"`, `"Hz"` |
|
|
82
|
+
| `overwrite` | replace an existing signal with the same name; raises `ScryLabError` if `False` (default) and a signal with that name already exists |
|
|
83
|
+
|
|
84
|
+
### `scry.send_many(y, …)`
|
|
85
|
+
|
|
86
|
+
Sends multiple signals at once. `y` can be a list of arrays or a `pandas.DataFrame` (each column becomes a signal).
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
scry.send_many(
|
|
90
|
+
y=[channel_1, channel_2, channel_3],
|
|
91
|
+
names=["Speed", "RPM", "Torque"],
|
|
92
|
+
y_unit=["km/h", "1/min", "Nm"],
|
|
93
|
+
source="Testrun 02",
|
|
94
|
+
)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
| Parameter | Description |
|
|
98
|
+
|-----------|-------------|
|
|
99
|
+
| `y` | list of numpy arrays/lists/`pandas.Series`, or `pandas.DataFrame` (each column becomes one signal); Series names and indices are used automatically |
|
|
100
|
+
| `names` | list of signal names; auto-generated if omitted |
|
|
101
|
+
| `source` | [data source](https://docs.scrylab.de/docs/concepts/data-sources/) to send into, default `"Sent from API"`; created automatically if it doesn't exist yet |
|
|
102
|
+
| `x` | a single numpy array/list broadcast to all signals, or a list of numpy arrays/lists (one per signal) |
|
|
103
|
+
| `z` | ~~1D array (colored trace)~~ (coming soon) or 2D array/`pandas.DataFrame` (spectrogram) – broadcast a single value to all signals, or pass a list (one per signal) |
|
|
104
|
+
| `y_unit`, `x_unit`, `z_unit` | a single string applied to all signals, or a list of strings (one per signal) |
|
|
105
|
+
| `overwrite` | replace existing signals with the same name; raises `ScryLabError` if `False` (default) and a signal with that name already exists |
|
|
106
|
+
|
|
107
|
+
### `scry.plot(y, …)`
|
|
108
|
+
|
|
109
|
+
Same as `send()` but also opens the signal in a plot. Always uses `"Sent from API"` as the data source – use `send()` if you need a specific source. A new plot is created if none exists yet.
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
scry.plot(y, name="Accelerometer", y_unit="m/s²", x_unit="s")
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## pandas integration
|
|
116
|
+
|
|
117
|
+
Pass a `pandas.Series` as `y` and `name` and `x` are derived automatically from the series:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
import pandas as pd
|
|
121
|
+
import scrylab as scry
|
|
122
|
+
|
|
123
|
+
voltage = pd.Series(
|
|
124
|
+
data=[0.1, 0.4, 0.9, 0.7],
|
|
125
|
+
index=[0.0, 0.1, 0.2, 0.3],
|
|
126
|
+
name="Voltage",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
scry.send(voltage, y_unit="V", x_unit="s")
|
|
130
|
+
# equivalent to: scry.send(voltage.to_numpy(), name="Voltage", x=voltage.index, y_unit="V", x_unit="s")
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
You can still override `name` or `x` explicitly – they take precedence over the series metadata.
|
|
134
|
+
|
|
135
|
+
`send_many()` works the same way for a list of Series. Pass a `pandas.DataFrame` and every column becomes a signal, with the index shared as x-axis:
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
df = pd.DataFrame({"Speed": [...], "RPM": [...]}, index=t)
|
|
139
|
+
scry.send_many(df, source="Testrun 03", y_unit=["km/h", "1/min"], x_unit="s")
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Spectrograms
|
|
143
|
+
|
|
144
|
+
Pass a `pandas.DataFrame` as `z` to send a spectrogram – columns map to the x-axis (e.g. time), the index to the y-axis (e.g. frequency):
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
import numpy as np
|
|
148
|
+
import pandas as pd
|
|
149
|
+
from scipy.signal import spectrogram
|
|
150
|
+
|
|
151
|
+
fs = 1000 # Hz
|
|
152
|
+
t = np.linspace(0, 4, fs * 4, endpoint=False)
|
|
153
|
+
|
|
154
|
+
# 20 Hz for the first 2 s, then 80 Hz
|
|
155
|
+
y = np.where(t < 2, np.sin(2 * np.pi * 20 * t), np.sin(2 * np.pi * 80 * t))
|
|
156
|
+
|
|
157
|
+
f, t_bins, Sxx = spectrogram(y, fs=fs, nperseg=256)
|
|
158
|
+
Sxx_db = 10 * np.log10(Sxx + 1e-12)
|
|
159
|
+
|
|
160
|
+
spec = pd.DataFrame(Sxx_db, index=f, columns=t_bins)
|
|
161
|
+
scry.send(y=f, z=spec, name="Spectrogram", y_unit="Hz", x_unit="s", z_unit="dB")
|
|
162
|
+
```
|
scrylab-0.1.0/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# scrylab-python
|
|
2
|
+
|
|
3
|
+
Python wrapper for the [ScryLab](https://scrylab.de) REST API. Lets you send signals directly into a running ScryLab GUI from any Python environment – scripts, Jupyter notebooks or simulation pipelines – as a faster, more interactive alternative to matplotlib or plotly for exploratory signal analysis.
|
|
4
|
+
|
|
5
|
+
## What you can do
|
|
6
|
+
|
|
7
|
+
- Send 1D signals (time series, measurement channels, …) into ScryLab's data browser
|
|
8
|
+
- Send ~~colored lines (signal + scalar color axis)~~ (coming soon) or spectrograms (2D matrix)
|
|
9
|
+
- Convenience function: open signals directly in a plot from code
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
1. Prerequisite: the ScryLab GUI (desktop app) needs to be running on the same machine. [Installation Guide](https://docs.scrylab.de/docs/getting-started/installation/)
|
|
14
|
+
2. Install the wrapper: `pip install scrylab`
|
|
15
|
+
|
|
16
|
+
## Quick start
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
import scrylab as scry
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
t = np.linspace(0, 10, 1_000)
|
|
23
|
+
y = np.sin(2 * np.pi * 5 * t)
|
|
24
|
+
|
|
25
|
+
scry.plot(y, name="Example Signal", x=t, y_unit="V", x_unit="s") # Convenience function: send + plot in one step
|
|
26
|
+
|
|
27
|
+
# Or send without plotting – then you can drag the signal into plots manually from the data browser
|
|
28
|
+
scry.send(y, name="Example Signal via send", x=t, y_unit="V", x_unit="s")
|
|
29
|
+
|
|
30
|
+
# Update the "Example Signal"
|
|
31
|
+
y2 = np.sin(2 * np.pi * 10 * t)
|
|
32
|
+
scry.send(y2, name="Example Signal", x=t, y_unit="V", x_unit="s", overwrite=True) # overwrite=True replaces the existing signal with the same name
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
### `scry.send(y, …)`
|
|
38
|
+
|
|
39
|
+
Sends a signal into a data source without automatic plotting.
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
scry.send(y, name="Speed", source="Testrun 01", x=t, y_unit="km/h", x_unit="s")
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
| Parameter | Description |
|
|
46
|
+
|-----------|-------------|
|
|
47
|
+
| `y` | numpy array, list, or `pandas.Series`; for a Series, `name` and `x` default to the series' name and index |
|
|
48
|
+
| `name` | signal name (auto-generated if omitted) |
|
|
49
|
+
| `source` | [data source](https://docs.scrylab.de/docs/concepts/data-sources/) to send into, default `"Sent from API"`; created automatically if it doesn't exist yet |
|
|
50
|
+
| `x` | numpy array, list, or `pandas.Series`/`Index`; auto-generated if omitted |
|
|
51
|
+
| `z` | optional color axis – 1D array/list/`pandas.Series` (one value per sample, colors the trace) or 2D array/`pandas.DataFrame` (matrix → spectrogram; columns map to x-axis, index to y-axis) |
|
|
52
|
+
| `y_unit`, `x_unit`, `z_unit` | axis units, e.g. `"V"`, `"s"`, `"Hz"` |
|
|
53
|
+
| `overwrite` | replace an existing signal with the same name; raises `ScryLabError` if `False` (default) and a signal with that name already exists |
|
|
54
|
+
|
|
55
|
+
### `scry.send_many(y, …)`
|
|
56
|
+
|
|
57
|
+
Sends multiple signals at once. `y` can be a list of arrays or a `pandas.DataFrame` (each column becomes a signal).
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
scry.send_many(
|
|
61
|
+
y=[channel_1, channel_2, channel_3],
|
|
62
|
+
names=["Speed", "RPM", "Torque"],
|
|
63
|
+
y_unit=["km/h", "1/min", "Nm"],
|
|
64
|
+
source="Testrun 02",
|
|
65
|
+
)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
| Parameter | Description |
|
|
69
|
+
|-----------|-------------|
|
|
70
|
+
| `y` | list of numpy arrays/lists/`pandas.Series`, or `pandas.DataFrame` (each column becomes one signal); Series names and indices are used automatically |
|
|
71
|
+
| `names` | list of signal names; auto-generated if omitted |
|
|
72
|
+
| `source` | [data source](https://docs.scrylab.de/docs/concepts/data-sources/) to send into, default `"Sent from API"`; created automatically if it doesn't exist yet |
|
|
73
|
+
| `x` | a single numpy array/list broadcast to all signals, or a list of numpy arrays/lists (one per signal) |
|
|
74
|
+
| `z` | ~~1D array (colored trace)~~ (coming soon) or 2D array/`pandas.DataFrame` (spectrogram) – broadcast a single value to all signals, or pass a list (one per signal) |
|
|
75
|
+
| `y_unit`, `x_unit`, `z_unit` | a single string applied to all signals, or a list of strings (one per signal) |
|
|
76
|
+
| `overwrite` | replace existing signals with the same name; raises `ScryLabError` if `False` (default) and a signal with that name already exists |
|
|
77
|
+
|
|
78
|
+
### `scry.plot(y, …)`
|
|
79
|
+
|
|
80
|
+
Same as `send()` but also opens the signal in a plot. Always uses `"Sent from API"` as the data source – use `send()` if you need a specific source. A new plot is created if none exists yet.
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
scry.plot(y, name="Accelerometer", y_unit="m/s²", x_unit="s")
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## pandas integration
|
|
87
|
+
|
|
88
|
+
Pass a `pandas.Series` as `y` and `name` and `x` are derived automatically from the series:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
import pandas as pd
|
|
92
|
+
import scrylab as scry
|
|
93
|
+
|
|
94
|
+
voltage = pd.Series(
|
|
95
|
+
data=[0.1, 0.4, 0.9, 0.7],
|
|
96
|
+
index=[0.0, 0.1, 0.2, 0.3],
|
|
97
|
+
name="Voltage",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
scry.send(voltage, y_unit="V", x_unit="s")
|
|
101
|
+
# equivalent to: scry.send(voltage.to_numpy(), name="Voltage", x=voltage.index, y_unit="V", x_unit="s")
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
You can still override `name` or `x` explicitly – they take precedence over the series metadata.
|
|
105
|
+
|
|
106
|
+
`send_many()` works the same way for a list of Series. Pass a `pandas.DataFrame` and every column becomes a signal, with the index shared as x-axis:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
df = pd.DataFrame({"Speed": [...], "RPM": [...]}, index=t)
|
|
110
|
+
scry.send_many(df, source="Testrun 03", y_unit=["km/h", "1/min"], x_unit="s")
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Spectrograms
|
|
114
|
+
|
|
115
|
+
Pass a `pandas.DataFrame` as `z` to send a spectrogram – columns map to the x-axis (e.g. time), the index to the y-axis (e.g. frequency):
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
import numpy as np
|
|
119
|
+
import pandas as pd
|
|
120
|
+
from scipy.signal import spectrogram
|
|
121
|
+
|
|
122
|
+
fs = 1000 # Hz
|
|
123
|
+
t = np.linspace(0, 4, fs * 4, endpoint=False)
|
|
124
|
+
|
|
125
|
+
# 20 Hz for the first 2 s, then 80 Hz
|
|
126
|
+
y = np.where(t < 2, np.sin(2 * np.pi * 20 * t), np.sin(2 * np.pi * 80 * t))
|
|
127
|
+
|
|
128
|
+
f, t_bins, Sxx = spectrogram(y, fs=fs, nperseg=256)
|
|
129
|
+
Sxx_db = 10 * np.log10(Sxx + 1e-12)
|
|
130
|
+
|
|
131
|
+
spec = pd.DataFrame(Sxx_db, index=f, columns=t_bins)
|
|
132
|
+
scry.send(y=f, z=spec, name="Spectrogram", y_unit="Hz", x_unit="s", z_unit="dB")
|
|
133
|
+
```
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.26"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "scrylab"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for the ScryLab GUI – send signals into a live ScryLab session from scripts, notebooks or simulation pipelines"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "ScryLab", email = "support@scrylab.de" },
|
|
15
|
+
]
|
|
16
|
+
keywords = ["scrylab", "signal", "measurement", "visualization", "time-series", "plot"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Intended Audience :: Science/Research",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Scientific/Engineering",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"requests>=2.28",
|
|
30
|
+
"numpy>=1.24",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Repository = "https://github.com/scrylab/scrylab-python"
|
|
35
|
+
Issues = "https://github.com/scrylab/scrylab-python/issues"
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
pandas = ["pandas>=1.5"]
|
|
39
|
+
dev = ["pytest>=7", "pandas>=1.5"]
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["src/scrylab"]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""
|
|
2
|
+
scrylab – Python client for ScryLab.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from ._client import ScryLabError, _default_client
|
|
6
|
+
from ._utils import normalize_one, normalize_many
|
|
7
|
+
from typing import Optional, Union
|
|
8
|
+
|
|
9
|
+
ScryLabError.__module__ = "scrylab"
|
|
10
|
+
|
|
11
|
+
__all__ = ["send", "send_many", "plot", "ScryLabError"]
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def send(
|
|
16
|
+
y,
|
|
17
|
+
name: Optional[str] = None,
|
|
18
|
+
source: str = "Sent from API",
|
|
19
|
+
x=None,
|
|
20
|
+
z=None,
|
|
21
|
+
y_unit: Optional[str] = None,
|
|
22
|
+
x_unit: Optional[str] = None,
|
|
23
|
+
z_unit: Optional[str] = None,
|
|
24
|
+
overwrite: bool = False,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Send a single signal to ScryLab without plotting.
|
|
27
|
+
|
|
28
|
+
y accepts a numpy array, a plain list, or a pandas Series.
|
|
29
|
+
For a Series, x defaults to the index and name to the series name.
|
|
30
|
+
x accepts a numpy array, list, or pandas Series/Index.
|
|
31
|
+
z is optional – pass a 1D array/list/Series for a color axis or a 2D array/DataFrame for a spectrogram.
|
|
32
|
+
source is created automatically if it doesn't exist yet.
|
|
33
|
+
Raises ScryLabError on failure or if the name already exists (overwrite=False).
|
|
34
|
+
"""
|
|
35
|
+
try:
|
|
36
|
+
yi, ni, xi, zi = normalize_one(y, name, x, z)
|
|
37
|
+
client = _default_client
|
|
38
|
+
source_id = client._resolve_source(source)
|
|
39
|
+
client.send_one(yi, ni, source_id, xi, zi, y_unit, x_unit, z_unit, overwrite=overwrite)
|
|
40
|
+
except ScryLabError as e:
|
|
41
|
+
raise ScryLabError(str(e)) from None
|
|
42
|
+
except Exception as e:
|
|
43
|
+
raise ScryLabError(str(e)) from None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def send_many(
|
|
47
|
+
y,
|
|
48
|
+
names=None,
|
|
49
|
+
source: str = "Sent from API",
|
|
50
|
+
x=None,
|
|
51
|
+
z=None,
|
|
52
|
+
y_unit: Union[str, list, None] = None,
|
|
53
|
+
x_unit: Union[str, list, None] = None,
|
|
54
|
+
z_unit: Union[str, list, None] = None,
|
|
55
|
+
overwrite: bool = False,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Send multiple signals to ScryLab without plotting.
|
|
58
|
+
|
|
59
|
+
y accepts:
|
|
60
|
+
- a list of arrays, lists, or pandas Series: each element is one signal
|
|
61
|
+
- a pandas DataFrame: each column is one signal, index as x
|
|
62
|
+
|
|
63
|
+
x and z can each be a list (one entry per signal) or a single value broadcast to all.
|
|
64
|
+
z accepts a 1D array (color axis) or 2D matrix (spectrogram) per signal.
|
|
65
|
+
y_unit, x_unit, z_unit each accept a single string (applied to all signals) or a list
|
|
66
|
+
(one unit per signal).
|
|
67
|
+
Raises ScryLabError on failure.
|
|
68
|
+
"""
|
|
69
|
+
try:
|
|
70
|
+
ys, ns, xs, zs = normalize_many(y, names, x, z)
|
|
71
|
+
client = _default_client
|
|
72
|
+
source_id = client._resolve_source(source)
|
|
73
|
+
client.send(ys, ns, source_id, xs, zs, y_unit, x_unit, z_unit, overwrite=overwrite)
|
|
74
|
+
except ScryLabError as e:
|
|
75
|
+
raise ScryLabError(str(e)) from None
|
|
76
|
+
except Exception as e:
|
|
77
|
+
raise ScryLabError(str(e)) from None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def plot(
|
|
81
|
+
y,
|
|
82
|
+
name: Optional[str] = None,
|
|
83
|
+
x=None,
|
|
84
|
+
z=None,
|
|
85
|
+
y_unit: Optional[str] = None,
|
|
86
|
+
x_unit: Optional[str] = None,
|
|
87
|
+
z_unit: Optional[str] = None,
|
|
88
|
+
overwrite: bool = False,
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Send a single signal to ScryLab and plot a signal-instance.
|
|
91
|
+
|
|
92
|
+
Accepts the same y, x, z types as send(). Always lands in data source "Sent from API".
|
|
93
|
+
A new plot is created if none exists.
|
|
94
|
+
"""
|
|
95
|
+
try:
|
|
96
|
+
yi, ni, xi, zi = normalize_one(y, name, x, z)
|
|
97
|
+
client = _default_client
|
|
98
|
+
source_id = client._resolve_source("Sent from API")
|
|
99
|
+
results = client.send_one(yi, ni, source_id, xi, zi, y_unit, x_unit, z_unit, overwrite=overwrite)
|
|
100
|
+
for r in results:
|
|
101
|
+
client.plot(r["signal_id"])
|
|
102
|
+
except ScryLabError as e:
|
|
103
|
+
raise ScryLabError(str(e)) from None
|
|
104
|
+
except Exception as e:
|
|
105
|
+
raise ScryLabError(str(e)) from None
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
_MIN_APP_VERSION = "0.1.10"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _parse_version(v: str) -> tuple:
|
|
12
|
+
try:
|
|
13
|
+
return tuple(int(x) for x in v.split("."))
|
|
14
|
+
except (ValueError, AttributeError):
|
|
15
|
+
return (0,)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ScryLabError(Exception):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _Client:
|
|
23
|
+
def __init__(self, host: str = "127.0.0.1", port: int = 5678, timeout: float = 30):
|
|
24
|
+
self._base = f"http://{host}:{port}"
|
|
25
|
+
self._timeout = timeout
|
|
26
|
+
self._session = requests.Session()
|
|
27
|
+
self._version_checked = False
|
|
28
|
+
|
|
29
|
+
def _url(self, path):
|
|
30
|
+
return f"{self._base}{path}"
|
|
31
|
+
|
|
32
|
+
def _request(self, method, path, **kwargs):
|
|
33
|
+
try:
|
|
34
|
+
return self._check(self._session.request(method, self._url(path), timeout=self._timeout, **kwargs))
|
|
35
|
+
except ScryLabError:
|
|
36
|
+
raise
|
|
37
|
+
except Exception:
|
|
38
|
+
raise ScryLabError("ScryLab desktop app is not running or unreachable – download it at https://scrylab.de/download.") from None
|
|
39
|
+
|
|
40
|
+
def _get(self, path):
|
|
41
|
+
return self._request("GET", path)
|
|
42
|
+
|
|
43
|
+
def _post(self, path, body=None, *, files=None, data=None):
|
|
44
|
+
return self._request("POST", path, json=body, files=files, data=data)
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def _check(resp):
|
|
48
|
+
try:
|
|
49
|
+
data = resp.json()
|
|
50
|
+
except ValueError:
|
|
51
|
+
resp.raise_for_status()
|
|
52
|
+
return {}
|
|
53
|
+
if resp.status_code == 404:
|
|
54
|
+
raise ScryLabError(f"Feature not available – please update ScryLab to v{_MIN_APP_VERSION} or later")
|
|
55
|
+
if resp.status_code >= 400:
|
|
56
|
+
raise ScryLabError(f"ScryLab error ({resp.status_code}): {data.get('error', resp.text)}")
|
|
57
|
+
if data.get("status") == "error":
|
|
58
|
+
raise ScryLabError(data.get("error", "Unknown error"))
|
|
59
|
+
return data
|
|
60
|
+
|
|
61
|
+
def _ensure_version(self):
|
|
62
|
+
if self._version_checked:
|
|
63
|
+
return
|
|
64
|
+
data = self._get("/api/status")
|
|
65
|
+
app_ver = data.get("version", "")
|
|
66
|
+
if _parse_version(app_ver) < _parse_version(_MIN_APP_VERSION):
|
|
67
|
+
raise ScryLabError(
|
|
68
|
+
f"ScryLab v{app_ver} is too old – please update to v{_MIN_APP_VERSION} or later"
|
|
69
|
+
)
|
|
70
|
+
self._version_checked = True
|
|
71
|
+
|
|
72
|
+
def _resolve_source(self, name: str) -> str:
|
|
73
|
+
self._ensure_version()
|
|
74
|
+
data = self._get("/api/sources")
|
|
75
|
+
for s in (data.get("result") or []):
|
|
76
|
+
if s.get("name") == name:
|
|
77
|
+
return s["source_id"]
|
|
78
|
+
created = self._post("/api/sources", {"name": name})
|
|
79
|
+
return (created.get("result") or {})["source_id"]
|
|
80
|
+
|
|
81
|
+
def send(self, ys: list, names: list, source_id: str, xs: list, zs: list,
|
|
82
|
+
y_units, x_units, z_units,
|
|
83
|
+
overwrite: bool = False) -> list[dict]:
|
|
84
|
+
import numpy as np
|
|
85
|
+
|
|
86
|
+
n = len(ys)
|
|
87
|
+
def _norm(u):
|
|
88
|
+
if u is None or isinstance(u, str):
|
|
89
|
+
return [u] * n
|
|
90
|
+
lst = list(u)
|
|
91
|
+
return lst * n if len(lst) == 1 else lst
|
|
92
|
+
|
|
93
|
+
y_units, x_units, z_units = _norm(y_units), _norm(x_units), _norm(z_units)
|
|
94
|
+
files, metas = [], []
|
|
95
|
+
for yi, ni, xi, zi, yu, xu, zu in zip(ys, names, xs, zs, y_units, x_units, z_units):
|
|
96
|
+
buf = io.BytesIO()
|
|
97
|
+
arrays = {"y": np.asarray(yi)}
|
|
98
|
+
if xi is not None: arrays["x"] = np.asarray(xi)
|
|
99
|
+
if zi is not None: arrays["z"] = np.asarray(zi)
|
|
100
|
+
np.savez(buf, **arrays)
|
|
101
|
+
buf.seek(0)
|
|
102
|
+
files.append(("file", (f"{ni}.npz", buf.read(), "application/octet-stream")))
|
|
103
|
+
m = {"name": ni, "target_source_id": source_id}
|
|
104
|
+
if yu is not None: m["y_unit"] = yu
|
|
105
|
+
if xu is not None: m["x_unit"] = xu
|
|
106
|
+
if zu is not None: m["z_unit"] = zu
|
|
107
|
+
if overwrite: m["overwrite"] = True
|
|
108
|
+
metas.append(m)
|
|
109
|
+
|
|
110
|
+
data = self._post("/api/signals/upload_batch", files=files, data={"meta": json.dumps(metas)})
|
|
111
|
+
result = data.get("result") or {}
|
|
112
|
+
errors = result.get("errors", [])
|
|
113
|
+
if errors:
|
|
114
|
+
details = "; ".join(f"#{e['index']}: {e['error']}" for e in errors)
|
|
115
|
+
raise ScryLabError(f"{len(errors)} signal(s) failed: {details}")
|
|
116
|
+
return result.get("signals", [])
|
|
117
|
+
|
|
118
|
+
def send_one(self, y, name: str, source_id: str, x, z,
|
|
119
|
+
y_unit, x_unit, z_unit, overwrite: bool = False) -> list[dict]:
|
|
120
|
+
return self.send([y], [name], source_id, [x], [z],
|
|
121
|
+
y_unit, x_unit, z_unit, overwrite=overwrite)
|
|
122
|
+
|
|
123
|
+
def plot(self, signal_id: str):
|
|
124
|
+
self._post("/api/plot", {"signal_id": signal_id})
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
_default_client = _Client()
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _broadcast(val, n):
|
|
6
|
+
return list(val) if isinstance(val, (list, tuple)) else [val] * n
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def normalize_one(y, name: Optional[str], x, z):
|
|
10
|
+
try:
|
|
11
|
+
import pandas as pd
|
|
12
|
+
if isinstance(y, pd.Series):
|
|
13
|
+
name = name or y.name
|
|
14
|
+
x = x if x is not None else y.index.to_numpy()
|
|
15
|
+
y = y.to_numpy()
|
|
16
|
+
if isinstance(z, pd.DataFrame):
|
|
17
|
+
x = x if x is not None else z.columns.to_numpy()
|
|
18
|
+
z = z.to_numpy()
|
|
19
|
+
elif isinstance(z, pd.Series):
|
|
20
|
+
z = z.to_numpy()
|
|
21
|
+
except ImportError:
|
|
22
|
+
pass
|
|
23
|
+
if x is not None:
|
|
24
|
+
x = np.asarray(x)
|
|
25
|
+
if z is not None:
|
|
26
|
+
z = np.asarray(z)
|
|
27
|
+
return np.asarray(y), name or "Unnamed Signal 1", x, z
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def normalize_many(y, names, x, z):
|
|
31
|
+
try:
|
|
32
|
+
import pandas as pd
|
|
33
|
+
except ImportError:
|
|
34
|
+
pd = None
|
|
35
|
+
|
|
36
|
+
if pd is not None and isinstance(y, pd.DataFrame):
|
|
37
|
+
cols = list(y.columns)
|
|
38
|
+
ys = [y[col].to_numpy() for col in cols]
|
|
39
|
+
names = names if names is not None else cols
|
|
40
|
+
x_eff = y.index.to_numpy() if x is None else x
|
|
41
|
+
xs = [np.asarray(xi) if xi is not None else None for xi in _broadcast(x_eff, len(ys))]
|
|
42
|
+
zs = [np.asarray(zi) if zi is not None else None for zi in _broadcast(z, len(ys))]
|
|
43
|
+
return ys, names, xs, zs
|
|
44
|
+
|
|
45
|
+
y_items = list(y)
|
|
46
|
+
n = len(y_items)
|
|
47
|
+
ys, auto_names, xs, zs = [], [], [], []
|
|
48
|
+
for yi, xi, zi in zip(y_items, _broadcast(x, n), _broadcast(z, n)):
|
|
49
|
+
if pd is not None and isinstance(yi, pd.Series):
|
|
50
|
+
auto_names.append(yi.name)
|
|
51
|
+
xs.append(np.asarray(xi) if xi is not None else yi.index.to_numpy())
|
|
52
|
+
ys.append(yi.to_numpy())
|
|
53
|
+
else:
|
|
54
|
+
auto_names.append(None)
|
|
55
|
+
xs.append(np.asarray(xi) if xi is not None else None)
|
|
56
|
+
ys.append(np.asarray(yi))
|
|
57
|
+
zs.append(np.asarray(zi) if zi is not None else None)
|
|
58
|
+
if names is None:
|
|
59
|
+
names = [an or f"Unnamed Signal {i + 1}" for i, an in enumerate(auto_names)]
|
|
60
|
+
else:
|
|
61
|
+
names = list(names)
|
|
62
|
+
return ys, names, xs, zs
|
|
File without changes
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
|
|
4
|
+
from scrylab._utils import normalize_one, normalize_many
|
|
5
|
+
|
|
6
|
+
# --- normalize_one ---
|
|
7
|
+
|
|
8
|
+
def test_normalize_one_numpy_array():
|
|
9
|
+
y = np.array([1.0, 2.0, 3.0])
|
|
10
|
+
yi, name, xi, zi = normalize_one(y, None, None, None)
|
|
11
|
+
assert isinstance(yi, np.ndarray)
|
|
12
|
+
np.testing.assert_array_equal(yi, y)
|
|
13
|
+
assert name == "Unnamed Signal 1"
|
|
14
|
+
assert xi is None
|
|
15
|
+
assert zi is None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_normalize_one_series_name_and_index():
|
|
19
|
+
s = pd.Series([10.0, 20.0], index=[0.0, 0.5], name="Speed")
|
|
20
|
+
yi, name, xi, zi = normalize_one(s, None, None, None)
|
|
21
|
+
assert name == "Speed"
|
|
22
|
+
np.testing.assert_array_equal(xi, [0.0, 0.5])
|
|
23
|
+
np.testing.assert_array_equal(yi, [10.0, 20.0])
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_normalize_one_series_x_override():
|
|
27
|
+
s = pd.Series([10.0, 20.0], index=[0.0, 0.5], name="Speed")
|
|
28
|
+
x = np.array([1.0, 2.0])
|
|
29
|
+
_, _, xi, _ = normalize_one(s, None, x, None)
|
|
30
|
+
np.testing.assert_array_equal(xi, x)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_normalize_one_z_dataframe():
|
|
34
|
+
z_df = pd.DataFrame([[1, 2], [3, 4]], index=[10.0, 20.0], columns=[0.0, 1.0])
|
|
35
|
+
_, _, xi, zi = normalize_one(np.array([10.0, 20.0]), None, None, z_df)
|
|
36
|
+
np.testing.assert_array_equal(zi, z_df.to_numpy())
|
|
37
|
+
np.testing.assert_array_equal(xi, z_df.columns.to_numpy())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_normalize_one_custom_name_overrides_series():
|
|
41
|
+
s = pd.Series([1.0, 2.0], name="Original")
|
|
42
|
+
_, name, _, _ = normalize_one(s, "Custom", None, None)
|
|
43
|
+
assert name == "Custom"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# --- normalize_many ---
|
|
47
|
+
|
|
48
|
+
def test_normalize_many_list_of_arrays():
|
|
49
|
+
a = np.array([1.0, 2.0])
|
|
50
|
+
b = np.array([3.0, 4.0])
|
|
51
|
+
ys, names, xs, zs = normalize_many([a, b], None, None, None)
|
|
52
|
+
assert names == ["Unnamed Signal 1", "Unnamed Signal 2"]
|
|
53
|
+
assert all(xi is None for xi in xs)
|
|
54
|
+
assert all(zi is None for zi in zs)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_normalize_many_dataframe_input():
|
|
58
|
+
df = pd.DataFrame({"Alpha": [1.0, 2.0], "Beta": [3.0, 4.0]}, index=[0.0, 1.0])
|
|
59
|
+
_, names, xs, _ = normalize_many(df, None, None, None)
|
|
60
|
+
assert names == ["Alpha", "Beta"]
|
|
61
|
+
np.testing.assert_array_equal(xs[0], df.index.to_numpy())
|
|
62
|
+
np.testing.assert_array_equal(xs[1], df.index.to_numpy())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_normalize_many_list_of_series():
|
|
66
|
+
s1 = pd.Series([1.0, 2.0], index=[0.0, 0.5], name="Temp")
|
|
67
|
+
s2 = pd.Series([3.0, 4.0], index=[0.0, 0.5], name="Pressure")
|
|
68
|
+
_, names, xs, _ = normalize_many([s1, s2], None, None, None)
|
|
69
|
+
assert names == ["Temp", "Pressure"]
|
|
70
|
+
np.testing.assert_array_equal(xs[0], s1.index.to_numpy())
|
|
71
|
+
np.testing.assert_array_equal(xs[1], s2.index.to_numpy())
|