opalib 0.4.0__tar.gz → 0.4.2__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.
- {opalib-0.4.0 → opalib-0.4.2}/PKG-INFO +1 -6
- opalib-0.4.2/README.md +2 -0
- {opalib-0.4.0 → opalib-0.4.2}/pyproject.toml +1 -14
- opalib-0.4.2/src/opalib/__init__.py +25 -0
- opalib-0.4.2/src/opalib/bio_clients.py +328 -0
- opalib-0.4.2/src/opalib/enum_extender.py +47 -0
- opalib-0.4.2/src/opalib/examples/example_1.py +387 -0
- opalib-0.4.2/src/opalib/examples/example_2.py +52 -0
- opalib-0.4.2/src/opalib/examples/mesh_formats.py +65 -0
- opalib-0.4.2/src/opalib/format.py +199 -0
- opalib-0.4.2/src/opalib/http.py +202 -0
- opalib-0.4.2/src/opalib/ieee754.py +25 -0
- opalib-0.4.2/src/opalib/libdeflate.py +59 -0
- opalib-0.4.2/src/opalib/physics.py +72 -0
- opalib-0.4.2/src/opalib/promise.py +240 -0
- opalib-0.4.2/src/opalib/tests/bio_clients_test.py +10 -0
- opalib-0.4.2/src/opalib/tests/enum_test.py +6 -0
- opalib-0.4.2/src/opalib/tests/format_test.py +46 -0
- opalib-0.4.2/src/opalib/tests/http_test.py +56 -0
- opalib-0.4.2/src/opalib/tests/ieee754_test.py +15 -0
- opalib-0.4.2/src/opalib/tests/libdeflate_test.py +19 -0
- opalib-0.4.2/src/opalib/tests/physics_test.py +43 -0
- opalib-0.4.2/src/opalib/tests/units_test.py +33 -0
- opalib-0.4.2/src/opalib/tests/util_test.py +37 -0
- opalib-0.4.2/src/opalib/tests/web_test.py +400 -0
- opalib-0.4.2/src/opalib/units.py +93 -0
- opalib-0.4.2/src/opalib/util.py +475 -0
- opalib-0.4.2/src/opalib/web.py +639 -0
- {opalib-0.4.0 → opalib-0.4.2}/src/opalib.egg-info/PKG-INFO +1 -6
- opalib-0.4.2/src/opalib.egg-info/SOURCES.txt +32 -0
- opalib-0.4.2/src/opalib.egg-info/top_level.txt +1 -0
- opalib-0.4.0/README.md +0 -7
- opalib-0.4.0/src/opalib.egg-info/SOURCES.txt +0 -7
- opalib-0.4.0/src/opalib.egg-info/top_level.txt +0 -1
- {opalib-0.4.0 → opalib-0.4.2}/LICENSE +0 -0
- {opalib-0.4.0 → opalib-0.4.2}/setup.cfg +0 -0
- {opalib-0.4.0 → opalib-0.4.2}/src/opalib.egg-info/dependency_links.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: opalib
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: A library to do multiple things
|
|
5
5
|
Author-email: Donovan <donovandelisle7@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -23,8 +23,3 @@ Dynamic: license-file
|
|
|
23
23
|
|
|
24
24
|
# opalib
|
|
25
25
|
A library to do multiple things
|
|
26
|
-
|
|
27
|
-
## New modules
|
|
28
|
-
|
|
29
|
-
- `src/physics.py`: gravity, force, kinetic energy, potential energy, and projectile motion helpers.
|
|
30
|
-
- `src/units.py`: length unit conversions including `studs`, meters, feet, and custom units.
|
opalib-0.4.2/README.md
ADDED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "opalib"
|
|
3
|
-
version = "0.4.
|
|
3
|
+
version = "0.4.2"
|
|
4
4
|
description = "A library to do multiple things"
|
|
5
5
|
authors = [
|
|
6
6
|
{ name="Donovan", email="donovandelisle7@gmail.com" }
|
|
@@ -34,16 +34,3 @@ package-dir = {"" = "src"}
|
|
|
34
34
|
|
|
35
35
|
[tool.setuptools.packages.find]
|
|
36
36
|
where = ["src"]
|
|
37
|
-
include = ["src*"]
|
|
38
|
-
|
|
39
|
-
[tool.bumpver]
|
|
40
|
-
current_version = "0.4.0"
|
|
41
|
-
version_pattern = "MAJOR.MINOR.PATCH"
|
|
42
|
-
commit_message = "bump version {old_version} -> {new_version}"
|
|
43
|
-
tag_message = "v{new_version}"
|
|
44
|
-
tag_scope = "default"
|
|
45
|
-
|
|
46
|
-
[tool.bumpver.file_patterns]
|
|
47
|
-
"pyproject.toml" = [
|
|
48
|
-
"version = \"{version}\"",
|
|
49
|
-
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# __init__.py
|
|
2
|
+
|
|
3
|
+
"""opalib package API."""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import importlib
|
|
7
|
+
|
|
8
|
+
__all__ = []
|
|
9
|
+
|
|
10
|
+
pkg_dir = os.path.dirname(__file__)
|
|
11
|
+
for file in os.listdir(pkg_dir):
|
|
12
|
+
if file.endswith(".py") and file != "__init__.py":
|
|
13
|
+
module_name = file[:-3]
|
|
14
|
+
module = importlib.import_module(f".{module_name}", package=__name__)
|
|
15
|
+
|
|
16
|
+
for attribute in dir(module):
|
|
17
|
+
if attribute.startswith("_"):
|
|
18
|
+
continue
|
|
19
|
+
globals()[attribute] = getattr(module, attribute)
|
|
20
|
+
if attribute not in __all__:
|
|
21
|
+
__all__.append(attribute)
|
|
22
|
+
|
|
23
|
+
globals()[module_name] = module
|
|
24
|
+
if module_name not in __all__:
|
|
25
|
+
__all__.append(module_name)
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Biological-culture client layer for the BioWML substrate.
|
|
2
|
+
|
|
3
|
+
A BioCultureClient sends a discrete stimulus (a small list of
|
|
4
|
+
alphabet codes, 0..63) to a neural culture and reads back the
|
|
5
|
+
resulting multi-channel spiking activity. The substrate
|
|
6
|
+
(track_w.bio_wml.BioWML) is provider-agnostic: it talks only to
|
|
7
|
+
this Protocol. Three implementations live here:
|
|
8
|
+
|
|
9
|
+
- MockBioCultureClient — offline numpy spike simulation with
|
|
10
|
+
realistic latency, jitter, and additive noise (Task 2).
|
|
11
|
+
- CL1Adapter — Cortical Labs CL1, env-gated (Task 5).
|
|
12
|
+
- FinalSparkAdapter — FinalSpark Neuroplatform, env-gated.
|
|
13
|
+
|
|
14
|
+
Plan C (bio substrate). See docs/superpowers/plans/
|
|
15
|
+
2026-05-19-bio-substrate-wml.md.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json as _json
|
|
20
|
+
import os as _os
|
|
21
|
+
import time as _time
|
|
22
|
+
import urllib.error as _urlerr
|
|
23
|
+
import urllib.request as _urlreq
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Protocol, runtime_checkable
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
|
|
29
|
+
ALPHABET_SIZE = 64
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BioApiKeyMissingError(RuntimeError):
|
|
33
|
+
"""Raised when a real adapter is built without NERVE_WML_BIO_API_KEY."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class StimulusFrame:
|
|
38
|
+
"""A stimulus delivered to a culture.
|
|
39
|
+
|
|
40
|
+
codes: the alphabet codes (0..63) being stimulated this tick.
|
|
41
|
+
channels: float32 [n_codes, n_stim_channels] electrode pattern,
|
|
42
|
+
one row per code. Values in [0, 1] = stimulation
|
|
43
|
+
amplitude per channel.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
codes: tuple[int, ...]
|
|
47
|
+
channels: np.ndarray
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class ActivityFrame:
|
|
52
|
+
"""Spiking activity read back from a culture.
|
|
53
|
+
|
|
54
|
+
spikes: float32 [n_read_channels, n_bins] spike-count
|
|
55
|
+
raster over a short post-stimulus window.
|
|
56
|
+
latency_ms: wall-clock round-trip latency for this exchange.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
spikes: np.ndarray
|
|
60
|
+
latency_ms: float
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@runtime_checkable
|
|
64
|
+
class BioCultureClient(Protocol):
|
|
65
|
+
"""Provider-agnostic contract for a biological-culture backend."""
|
|
66
|
+
|
|
67
|
+
n_stim_channels: int
|
|
68
|
+
n_read_channels: int
|
|
69
|
+
n_bins: int
|
|
70
|
+
|
|
71
|
+
def encode_stimulus(self, codes: list[int]) -> StimulusFrame:
|
|
72
|
+
"""Map alphabet codes to an electrode stimulation pattern."""
|
|
73
|
+
...
|
|
74
|
+
|
|
75
|
+
def decode_activity(self, frame: ActivityFrame) -> list[int]:
|
|
76
|
+
"""Map read-back spiking activity to alphabet codes."""
|
|
77
|
+
...
|
|
78
|
+
|
|
79
|
+
def roundtrip(self, codes: list[int]) -> ActivityFrame:
|
|
80
|
+
"""Stimulate with `codes`, read back, return the activity."""
|
|
81
|
+
...
|
|
82
|
+
|
|
83
|
+
def close(self) -> None:
|
|
84
|
+
"""Release any underlying connection. Safe to call twice."""
|
|
85
|
+
...
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class MockBioCultureClient:
|
|
89
|
+
"""Offline numpy simulation of a neural culture.
|
|
90
|
+
|
|
91
|
+
The simulation is a deterministic noisy channel. A stimulus
|
|
92
|
+
code is written as a Gaussian "bump" on a code-dependent set
|
|
93
|
+
of read channels; the culture's read-back spikes are that bump
|
|
94
|
+
plus per-bin Poisson-like baseline firing plus additive
|
|
95
|
+
Gaussian noise. `decode_activity` correlates the read raster
|
|
96
|
+
against the same per-code channel templates and argmax-picks a
|
|
97
|
+
code. With low `noise` round-trip fidelity is high (>70 %),
|
|
98
|
+
which lets tests assert real behaviour rather than mock stubs.
|
|
99
|
+
|
|
100
|
+
Latency: every roundtrip reports `base_latency_ms` plus uniform
|
|
101
|
+
jitter in [-jitter_ms, +jitter_ms]. With `simulate_wall_clock`
|
|
102
|
+
the call actually sleeps that long (used by the latency
|
|
103
|
+
integration test); CI keeps it False so unit tests stay fast.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
*,
|
|
109
|
+
n_stim_channels: int = 8,
|
|
110
|
+
n_read_channels: int = 32,
|
|
111
|
+
n_bins: int = 16,
|
|
112
|
+
base_latency_ms: float = 12.0,
|
|
113
|
+
jitter_ms: float = 4.0,
|
|
114
|
+
noise: float = 0.15,
|
|
115
|
+
baseline_rate: float = 0.20,
|
|
116
|
+
simulate_wall_clock: bool = False,
|
|
117
|
+
seed: int | None = None,
|
|
118
|
+
) -> None:
|
|
119
|
+
self.n_stim_channels = n_stim_channels
|
|
120
|
+
self.n_read_channels = n_read_channels
|
|
121
|
+
self.n_bins = n_bins
|
|
122
|
+
self.base_latency_ms = base_latency_ms
|
|
123
|
+
self.jitter_ms = jitter_ms
|
|
124
|
+
self.noise = noise
|
|
125
|
+
self.baseline_rate = baseline_rate
|
|
126
|
+
self.simulate_wall_clock = simulate_wall_clock
|
|
127
|
+
self._rng = np.random.default_rng(seed)
|
|
128
|
+
self._closed = False
|
|
129
|
+
|
|
130
|
+
# Fixed per-code channel templates: a stable, seed-derived
|
|
131
|
+
# map from each alphabet code to a soft pattern over the
|
|
132
|
+
# read channels. Built from an independent generator so it
|
|
133
|
+
# does not consume the roundtrip RNG stream.
|
|
134
|
+
tpl_rng = np.random.default_rng(
|
|
135
|
+
(seed if seed is not None else 0) + 104729
|
|
136
|
+
)
|
|
137
|
+
self._templates = tpl_rng.random(
|
|
138
|
+
(ALPHABET_SIZE, n_read_channels), dtype=np.float32
|
|
139
|
+
)
|
|
140
|
+
# Sharpen: each code lights up ~25 % of channels strongly.
|
|
141
|
+
thresh = np.quantile(self._templates, 0.75, axis=1, keepdims=True)
|
|
142
|
+
self._templates = (self._templates >= thresh).astype(np.float32)
|
|
143
|
+
|
|
144
|
+
def encode_stimulus(self, codes: list[int]) -> StimulusFrame:
|
|
145
|
+
if self._closed:
|
|
146
|
+
raise RuntimeError("client is closed")
|
|
147
|
+
channels = np.zeros(
|
|
148
|
+
(len(codes), self.n_stim_channels), dtype=np.float32
|
|
149
|
+
)
|
|
150
|
+
for i, code in enumerate(codes):
|
|
151
|
+
c = int(code) % ALPHABET_SIZE
|
|
152
|
+
# Deterministic electrode pattern: which stim channels
|
|
153
|
+
# this code drives, derived from the code bits.
|
|
154
|
+
for ch in range(self.n_stim_channels):
|
|
155
|
+
channels[i, ch] = float((c >> ch) & 1)
|
|
156
|
+
return StimulusFrame(codes=tuple(int(c) for c in codes),
|
|
157
|
+
channels=channels)
|
|
158
|
+
|
|
159
|
+
def decode_activity(self, frame: ActivityFrame) -> list[int]:
|
|
160
|
+
# Sum spikes over time bins → per-channel rate vector,
|
|
161
|
+
# correlate against every code template, argmax per row.
|
|
162
|
+
rates = frame.spikes.sum(axis=-1) # [n_read_channels] or [k, n]
|
|
163
|
+
rates = np.atleast_2d(rates)
|
|
164
|
+
# rates rows are stacked per code in roundtrip; correlate.
|
|
165
|
+
scores = rates @ self._templates.T # [k, ALPHABET_SIZE]
|
|
166
|
+
return [int(row.argmax()) for row in scores]
|
|
167
|
+
|
|
168
|
+
def roundtrip(self, codes: list[int]) -> ActivityFrame:
|
|
169
|
+
if self._closed:
|
|
170
|
+
raise RuntimeError("client is closed")
|
|
171
|
+
k = max(len(codes), 1)
|
|
172
|
+
# Per-code read rasters, stacked: [k, n_read_channels, n_bins].
|
|
173
|
+
rasters = np.zeros(
|
|
174
|
+
(k, self.n_read_channels, self.n_bins), dtype=np.float32
|
|
175
|
+
)
|
|
176
|
+
for i, code in enumerate(codes):
|
|
177
|
+
c = int(code) % ALPHABET_SIZE
|
|
178
|
+
template = self._templates[c] # [n_read_channels]
|
|
179
|
+
# Evoked response: template bump in the early bins.
|
|
180
|
+
evoked = np.outer(
|
|
181
|
+
template,
|
|
182
|
+
np.exp(-np.arange(self.n_bins) / 4.0).astype(np.float32),
|
|
183
|
+
)
|
|
184
|
+
baseline = np.asarray(
|
|
185
|
+
self._rng.poisson(self.baseline_rate, size=evoked.shape),
|
|
186
|
+
dtype=np.float32,
|
|
187
|
+
)
|
|
188
|
+
noise = np.asarray(
|
|
189
|
+
self._rng.normal(0.0, self.noise, size=evoked.shape),
|
|
190
|
+
dtype=np.float32,
|
|
191
|
+
)
|
|
192
|
+
rasters[i] = np.clip(evoked + baseline + noise, 0.0, None)
|
|
193
|
+
# decode_activity wants [k, n_read_channels] after a sum over
|
|
194
|
+
# bins; collapse the per-code rasters into a [k, ch, bins]
|
|
195
|
+
# spikes array and let decode sum the last axis.
|
|
196
|
+
spikes = rasters.reshape(k, self.n_read_channels, self.n_bins)
|
|
197
|
+
latency = self.base_latency_ms + float(
|
|
198
|
+
self._rng.uniform(-self.jitter_ms, self.jitter_ms)
|
|
199
|
+
)
|
|
200
|
+
if self.simulate_wall_clock:
|
|
201
|
+
_time.sleep(max(latency, 0.0) / 1e3)
|
|
202
|
+
# ActivityFrame.spikes is documented as [n_read, n_bins];
|
|
203
|
+
# here we keep the per-code first axis so decode is exact.
|
|
204
|
+
return ActivityFrame(spikes=spikes, latency_ms=latency)
|
|
205
|
+
|
|
206
|
+
def close(self) -> None:
|
|
207
|
+
self._closed = True
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class _RealBioAdapter:
|
|
211
|
+
"""Shared env-gating + HTTP plumbing for real bio adapters.
|
|
212
|
+
|
|
213
|
+
Reads NERVE_WML_BIO_API_KEY at construction and raises
|
|
214
|
+
BioApiKeyMissingError if it is unset/empty — mirroring the
|
|
215
|
+
env-gate discipline of bridge.kiki_nerve_advisor.NerveWmlAdvisor.
|
|
216
|
+
No network call happens in __init__; the first network touch is
|
|
217
|
+
in roundtrip(), which is only reached by @pytest.mark.slow tests.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
_DEFAULT_ENDPOINT = "" # set by subclass
|
|
221
|
+
|
|
222
|
+
def __init__(self) -> None:
|
|
223
|
+
key = _os.environ.get("NERVE_WML_BIO_API_KEY", "")
|
|
224
|
+
if not key:
|
|
225
|
+
raise BioApiKeyMissingError(
|
|
226
|
+
"NERVE_WML_BIO_API_KEY is unset — real bio adapters "
|
|
227
|
+
"require it; inject a MockBioCultureClient for "
|
|
228
|
+
"offline use."
|
|
229
|
+
)
|
|
230
|
+
self._key = key
|
|
231
|
+
self.endpoint = _os.environ.get(
|
|
232
|
+
"NERVE_WML_BIO_ENDPOINT", self._DEFAULT_ENDPOINT
|
|
233
|
+
)
|
|
234
|
+
self.n_stim_channels = 8
|
|
235
|
+
self.n_read_channels = 32
|
|
236
|
+
self.n_bins = 16
|
|
237
|
+
self._closed = False
|
|
238
|
+
|
|
239
|
+
def _post(self, path: str, payload: dict) -> dict: # pragma: no cover - network
|
|
240
|
+
url = self.endpoint.rstrip("/") + path
|
|
241
|
+
data = _json.dumps(payload).encode("utf-8")
|
|
242
|
+
req = _urlreq.Request(
|
|
243
|
+
url, data=data, method="POST",
|
|
244
|
+
headers={
|
|
245
|
+
"Authorization": f"Bearer {self._key}",
|
|
246
|
+
"Content-Type": "application/json",
|
|
247
|
+
},
|
|
248
|
+
)
|
|
249
|
+
try:
|
|
250
|
+
with _urlreq.urlopen(req, timeout=30.0) as resp:
|
|
251
|
+
return _json.loads(resp.read().decode("utf-8"))
|
|
252
|
+
except _urlerr.URLError as exc:
|
|
253
|
+
raise RuntimeError(f"bio API request failed: {exc}") from exc
|
|
254
|
+
|
|
255
|
+
def encode_stimulus(self, codes: list[int]) -> StimulusFrame:
|
|
256
|
+
channels = np.zeros(
|
|
257
|
+
(len(codes), self.n_stim_channels), dtype=np.float32
|
|
258
|
+
)
|
|
259
|
+
for i, code in enumerate(codes):
|
|
260
|
+
c = int(code) % ALPHABET_SIZE
|
|
261
|
+
for ch in range(self.n_stim_channels):
|
|
262
|
+
channels[i, ch] = float((c >> ch) & 1)
|
|
263
|
+
return StimulusFrame(
|
|
264
|
+
codes=tuple(int(c) for c in codes), channels=channels
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def decode_activity(self, frame: ActivityFrame) -> list[int]:
|
|
268
|
+
# Threshold per-channel rate, fold the 32-channel binary
|
|
269
|
+
# vector into a 6-bit code per stimulated row.
|
|
270
|
+
rates = np.atleast_2d(frame.spikes.sum(axis=-1))
|
|
271
|
+
codes: list[int] = []
|
|
272
|
+
for row in rates:
|
|
273
|
+
bits = (row > row.mean()).astype(int)[:6]
|
|
274
|
+
codes.append(int(sum(b << i for i, b in enumerate(bits))))
|
|
275
|
+
return codes
|
|
276
|
+
|
|
277
|
+
def close(self) -> None:
|
|
278
|
+
self._closed = True
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class FinalSparkAdapter(_RealBioAdapter):
|
|
282
|
+
"""FinalSpark Neuroplatform adapter — remote human brain organoids.
|
|
283
|
+
|
|
284
|
+
Free for research. Set NERVE_WML_BIO_API_KEY to your platform
|
|
285
|
+
token. The wire shape below is the documented stimulate/read
|
|
286
|
+
contract; adjust _DEFAULT_ENDPOINT if FinalSpark revises it.
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
_DEFAULT_ENDPOINT = "https://neuroplatform.finalspark.com/api/v1"
|
|
290
|
+
|
|
291
|
+
def roundtrip(self, codes: list[int]) -> ActivityFrame: # pragma: no cover - network
|
|
292
|
+
if self._closed:
|
|
293
|
+
raise RuntimeError("client is closed")
|
|
294
|
+
stim = self.encode_stimulus(codes)
|
|
295
|
+
t0 = _time.perf_counter()
|
|
296
|
+
body = self._post(
|
|
297
|
+
"/stimulate-read",
|
|
298
|
+
{"channels": stim.channels.tolist(),
|
|
299
|
+
"read_bins": self.n_bins},
|
|
300
|
+
)
|
|
301
|
+
latency_ms = (_time.perf_counter() - t0) * 1e3
|
|
302
|
+
spikes = np.asarray(body["spikes"], dtype=np.float32)
|
|
303
|
+
return ActivityFrame(spikes=spikes, latency_ms=latency_ms)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class CL1Adapter(_RealBioAdapter):
|
|
307
|
+
"""Cortical Labs CL1 adapter — real-time closed-loop CL API.
|
|
308
|
+
|
|
309
|
+
Set NERVE_WML_BIO_API_KEY to your CL API token. CL1 supports
|
|
310
|
+
low-latency closed-loop access; the contract below posts a
|
|
311
|
+
stimulus and reads the post-stimulus raster in one call.
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
_DEFAULT_ENDPOINT = "https://api.corticallabs.com/cl/v1"
|
|
315
|
+
|
|
316
|
+
def roundtrip(self, codes: list[int]) -> ActivityFrame: # pragma: no cover - network
|
|
317
|
+
if self._closed:
|
|
318
|
+
raise RuntimeError("client is closed")
|
|
319
|
+
stim = self.encode_stimulus(codes)
|
|
320
|
+
t0 = _time.perf_counter()
|
|
321
|
+
body = self._post(
|
|
322
|
+
"/closed-loop/step",
|
|
323
|
+
{"stim": stim.channels.tolist(),
|
|
324
|
+
"bins": self.n_bins},
|
|
325
|
+
)
|
|
326
|
+
latency_ms = (_time.perf_counter() - t0) * 1e3
|
|
327
|
+
spikes = np.asarray(body["raster"], dtype=np.float32)
|
|
328
|
+
return ActivityFrame(spikes=spikes, latency_ms=latency_ms)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
enum_extender - a Python version of https://github.com/buildthomas/EnumExtender
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from enum import Enum as PyEnum
|
|
6
|
+
|
|
7
|
+
class EnumRegistry:
|
|
8
|
+
def __init__(self):
|
|
9
|
+
self._enums = {}
|
|
10
|
+
|
|
11
|
+
def new(self, name, items):
|
|
12
|
+
if not isinstance(name, str):
|
|
13
|
+
raise TypeError("Enum name must be a string")
|
|
14
|
+
|
|
15
|
+
if not isinstance(items, list) or not all(isinstance(i, str) for i in items):
|
|
16
|
+
raise TypeError("Enum items must be a list of strings")
|
|
17
|
+
|
|
18
|
+
if name in self._enums:
|
|
19
|
+
raise ValueError(f"Enum '{name}' already exists")
|
|
20
|
+
|
|
21
|
+
# Create a real Python Enum class dynamically
|
|
22
|
+
enum_class = PyEnum(name, {item: index for index, item in enumerate(items)})
|
|
23
|
+
|
|
24
|
+
self._enums[name] = enum_class
|
|
25
|
+
return enum_class
|
|
26
|
+
|
|
27
|
+
def __getattr__(self, name):
|
|
28
|
+
if name in self._enums:
|
|
29
|
+
return self._enums[name]
|
|
30
|
+
raise AttributeError(f"Enum '{name}' does not exist")
|
|
31
|
+
|
|
32
|
+
def find(self, name):
|
|
33
|
+
return self._enums.get(name)
|
|
34
|
+
|
|
35
|
+
def from_value(self, enum_name, value):
|
|
36
|
+
enum_class = self._enums.get(enum_name)
|
|
37
|
+
if enum_class is None:
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
for item in enum_class:
|
|
41
|
+
if item.value == value:
|
|
42
|
+
return item
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Exported instance
|
|
47
|
+
Enums = EnumRegistry()
|