microcycler 0.1.0__py3-none-any.whl
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.
- microcycler/__init__.py +48 -0
- microcycler/client.py +187 -0
- microcycler/recipes.py +130 -0
- microcycler/streaming.py +115 -0
- microcycler/types.py +66 -0
- microcycler-0.1.0.dist-info/METADATA +124 -0
- microcycler-0.1.0.dist-info/RECORD +8 -0
- microcycler-0.1.0.dist-info/WHEEL +4 -0
microcycler/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Microcycler Python SDK
|
|
3
|
+
======================
|
|
4
|
+
|
|
5
|
+
Python SDK for the Microcycler battery testing platform.
|
|
6
|
+
|
|
7
|
+
Basic usage::
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from microcycler import Cycler
|
|
11
|
+
|
|
12
|
+
async def main():
|
|
13
|
+
async with Cycler("192.168.1.100") as cycler:
|
|
14
|
+
telemetry = await cycler.get_telemetry()
|
|
15
|
+
print(f"Voltage: {telemetry.voltage:.3f} V")
|
|
16
|
+
print(f"Current: {telemetry.current:.3f} A")
|
|
17
|
+
|
|
18
|
+
asyncio.run(main())
|
|
19
|
+
|
|
20
|
+
Recipe usage::
|
|
21
|
+
|
|
22
|
+
from microcycler import Cycler
|
|
23
|
+
from microcycler.recipes import Recipe, CC, CV, Rest, Loop
|
|
24
|
+
|
|
25
|
+
recipe = (
|
|
26
|
+
Recipe()
|
|
27
|
+
.add(CC(current=1.0, cutoff_voltage=4.2))
|
|
28
|
+
.add(CV(voltage=4.2, cutoff_current=0.05))
|
|
29
|
+
.add(Rest(duration=300))
|
|
30
|
+
.add(Loop(cycles=100))
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
async with Cycler("192.168.1.100") as cycler:
|
|
34
|
+
await cycler.load_recipe(recipe)
|
|
35
|
+
await cycler.start()
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from .client import Cycler
|
|
39
|
+
from .types import CyclerTelemetry, CyclerHealth, CyclerLimits, CyclerState
|
|
40
|
+
|
|
41
|
+
__version__ = "0.1.0"
|
|
42
|
+
__all__ = [
|
|
43
|
+
"Cycler",
|
|
44
|
+
"CyclerTelemetry",
|
|
45
|
+
"CyclerHealth",
|
|
46
|
+
"CyclerLimits",
|
|
47
|
+
"CyclerState",
|
|
48
|
+
]
|
microcycler/client.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TCP client for communicating with a Microcycler device.
|
|
3
|
+
|
|
4
|
+
The cycler exposes a SCPI-like command interface over TCP (port 5025) and
|
|
5
|
+
broadcasts UDP telemetry packets for discovery and streaming (port 5026).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
from types import TracebackType
|
|
13
|
+
from typing import Optional, Type
|
|
14
|
+
|
|
15
|
+
from .recipes import Recipe
|
|
16
|
+
from .types import CyclerHealth, CyclerTelemetry
|
|
17
|
+
|
|
18
|
+
_TCP_PORT = 5025
|
|
19
|
+
_CONNECT_TIMEOUT = 5.0
|
|
20
|
+
_COMMAND_TIMEOUT = 10.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CyclerError(Exception):
|
|
24
|
+
"""Raised when the cycler returns an error response."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Cycler:
|
|
28
|
+
"""
|
|
29
|
+
Async TCP client for a single Microcycler unit.
|
|
30
|
+
|
|
31
|
+
Usage as an async context manager is recommended::
|
|
32
|
+
|
|
33
|
+
async with Cycler("192.168.1.100") as cycler:
|
|
34
|
+
telemetry = await cycler.get_telemetry()
|
|
35
|
+
|
|
36
|
+
You may also manage the connection manually::
|
|
37
|
+
|
|
38
|
+
cycler = Cycler("192.168.1.100")
|
|
39
|
+
await cycler.connect()
|
|
40
|
+
try:
|
|
41
|
+
await cycler.start()
|
|
42
|
+
finally:
|
|
43
|
+
await cycler.disconnect()
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
host: str,
|
|
49
|
+
port: int = _TCP_PORT,
|
|
50
|
+
connect_timeout: float = _CONNECT_TIMEOUT,
|
|
51
|
+
command_timeout: float = _COMMAND_TIMEOUT,
|
|
52
|
+
) -> None:
|
|
53
|
+
self.host = host
|
|
54
|
+
self.port = port
|
|
55
|
+
self._connect_timeout = connect_timeout
|
|
56
|
+
self._command_timeout = command_timeout
|
|
57
|
+
self._reader: Optional[asyncio.StreamReader] = None
|
|
58
|
+
self._writer: Optional[asyncio.StreamWriter] = None
|
|
59
|
+
self._lock = asyncio.Lock()
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
# Connection management
|
|
63
|
+
# ------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
async def connect(self) -> None:
|
|
66
|
+
"""Open the TCP connection to the cycler."""
|
|
67
|
+
self._reader, self._writer = await asyncio.wait_for(
|
|
68
|
+
asyncio.open_connection(self.host, self.port),
|
|
69
|
+
timeout=self._connect_timeout,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
async def disconnect(self) -> None:
|
|
73
|
+
"""Close the TCP connection."""
|
|
74
|
+
if self._writer is not None:
|
|
75
|
+
self._writer.close()
|
|
76
|
+
try:
|
|
77
|
+
await self._writer.wait_closed()
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
self._writer = None
|
|
81
|
+
self._reader = None
|
|
82
|
+
|
|
83
|
+
async def __aenter__(self) -> "Cycler":
|
|
84
|
+
await self.connect()
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
async def __aexit__(
|
|
88
|
+
self,
|
|
89
|
+
exc_type: Optional[Type[BaseException]],
|
|
90
|
+
exc_val: Optional[BaseException],
|
|
91
|
+
exc_tb: Optional[TracebackType],
|
|
92
|
+
) -> None:
|
|
93
|
+
await self.disconnect()
|
|
94
|
+
|
|
95
|
+
# ------------------------------------------------------------------
|
|
96
|
+
# Low-level transport
|
|
97
|
+
# ------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
async def _send(self, command: str) -> str:
|
|
100
|
+
"""Send a SCPI command and return the response line."""
|
|
101
|
+
if self._writer is None or self._reader is None:
|
|
102
|
+
raise CyclerError("Not connected. Call connect() or use as async context manager.")
|
|
103
|
+
|
|
104
|
+
async with self._lock:
|
|
105
|
+
self._writer.write((command.strip() + "\n").encode())
|
|
106
|
+
await self._writer.drain()
|
|
107
|
+
response = await asyncio.wait_for(
|
|
108
|
+
self._reader.readline(),
|
|
109
|
+
timeout=self._command_timeout,
|
|
110
|
+
)
|
|
111
|
+
line = response.decode().strip()
|
|
112
|
+
if line.startswith("ERR"):
|
|
113
|
+
raise CyclerError(line)
|
|
114
|
+
return line
|
|
115
|
+
|
|
116
|
+
async def _query_json(self, command: str) -> dict:
|
|
117
|
+
raw = await self._send(command)
|
|
118
|
+
return json.loads(raw)
|
|
119
|
+
|
|
120
|
+
# ------------------------------------------------------------------
|
|
121
|
+
# Queries
|
|
122
|
+
# ------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
async def get_telemetry(self) -> CyclerTelemetry:
|
|
125
|
+
"""Return the current cell telemetry snapshot."""
|
|
126
|
+
data = await self._query_json("MEAS:TEL?")
|
|
127
|
+
return CyclerTelemetry.from_dict(data)
|
|
128
|
+
|
|
129
|
+
async def get_health(self) -> CyclerHealth:
|
|
130
|
+
"""Return the cycler hardware health snapshot."""
|
|
131
|
+
data = await self._query_json("MEAS:HEALTH?")
|
|
132
|
+
return CyclerHealth.from_dict(data)
|
|
133
|
+
|
|
134
|
+
async def identify(self) -> str:
|
|
135
|
+
"""Return the cycler identification string (*IDN?)."""
|
|
136
|
+
return await self._send("*IDN?")
|
|
137
|
+
|
|
138
|
+
# ------------------------------------------------------------------
|
|
139
|
+
# Control
|
|
140
|
+
# ------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
async def load_recipe(self, recipe: Recipe) -> None:
|
|
143
|
+
"""Upload a recipe to the cycler's RAM."""
|
|
144
|
+
payload = json.dumps(recipe.to_dict())
|
|
145
|
+
await self._send(f"REC:LOAD {payload}")
|
|
146
|
+
|
|
147
|
+
async def start(self) -> None:
|
|
148
|
+
"""Start the loaded recipe."""
|
|
149
|
+
await self._send("REC:START")
|
|
150
|
+
|
|
151
|
+
async def pause(self) -> None:
|
|
152
|
+
"""Pause the running recipe."""
|
|
153
|
+
await self._send("REC:PAUSE")
|
|
154
|
+
|
|
155
|
+
async def resume(self) -> None:
|
|
156
|
+
"""Resume a paused recipe."""
|
|
157
|
+
await self._send("REC:RESUME")
|
|
158
|
+
|
|
159
|
+
async def stop(self) -> None:
|
|
160
|
+
"""Abort the running recipe and return to IDLE."""
|
|
161
|
+
await self._send("REC:STOP")
|
|
162
|
+
|
|
163
|
+
async def set_output(self, *, voltage: float, current: float) -> None:
|
|
164
|
+
"""
|
|
165
|
+
Raw output override (host must remain connected).
|
|
166
|
+
Intended for direct/manual control outside of a recipe.
|
|
167
|
+
"""
|
|
168
|
+
await self._send(f"OUT:VOLT {voltage:.4f};OUT:CURR {current:.4f}")
|
|
169
|
+
|
|
170
|
+
async def set_limits(
|
|
171
|
+
self,
|
|
172
|
+
*,
|
|
173
|
+
voltage_upper: float,
|
|
174
|
+
voltage_lower: float,
|
|
175
|
+
current_upper: float,
|
|
176
|
+
temperature_upper: float,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Update the safety limits on the cycler."""
|
|
179
|
+
payload = json.dumps(
|
|
180
|
+
{
|
|
181
|
+
"voltage_upper": voltage_upper,
|
|
182
|
+
"voltage_lower": voltage_lower,
|
|
183
|
+
"current_upper": current_upper,
|
|
184
|
+
"temperature_upper": temperature_upper,
|
|
185
|
+
}
|
|
186
|
+
)
|
|
187
|
+
await self._send(f"LIM:SET {payload}")
|
microcycler/recipes.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Recipe DSL for building cycler test sequences.
|
|
3
|
+
|
|
4
|
+
A Recipe is an ordered list of steps. Steps are serialized to JSON and
|
|
5
|
+
uploaded to the cycler via the TCP client (REC:LOAD command).
|
|
6
|
+
|
|
7
|
+
Example::
|
|
8
|
+
|
|
9
|
+
from microcycler.recipes import Recipe, CC, CV, Rest, Loop
|
|
10
|
+
|
|
11
|
+
recipe = (
|
|
12
|
+
Recipe(name="Formation C/10")
|
|
13
|
+
.add(CC(current=0.1, cutoff_voltage=4.2, cutoff_time=36000))
|
|
14
|
+
.add(CV(voltage=4.2, cutoff_current=0.02))
|
|
15
|
+
.add(Rest(duration=1800))
|
|
16
|
+
.add(CC(current=-0.1, cutoff_voltage=2.8)) # discharge
|
|
17
|
+
.add(Rest(duration=1800))
|
|
18
|
+
.add(Loop(cycles=3))
|
|
19
|
+
)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import asdict, dataclass, field
|
|
25
|
+
from typing import Any, List, Optional
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ------------------------------------------------------------------
|
|
29
|
+
# Step base
|
|
30
|
+
# ------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class _Step:
|
|
35
|
+
def to_dict(self) -> dict:
|
|
36
|
+
d = asdict(self)
|
|
37
|
+
d["type"] = self.__class__.__name__.upper()
|
|
38
|
+
return {k: v for k, v in d.items() if v is not None}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ------------------------------------------------------------------
|
|
42
|
+
# Concrete step types
|
|
43
|
+
# ------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class CC(_Step):
|
|
48
|
+
"""Constant-current step."""
|
|
49
|
+
current: float
|
|
50
|
+
cutoff_voltage: Optional[float] = None
|
|
51
|
+
cutoff_time: Optional[float] = None # seconds
|
|
52
|
+
cutoff_capacity: Optional[float] = None # mAh
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class CV(_Step):
|
|
57
|
+
"""Constant-voltage step."""
|
|
58
|
+
voltage: float
|
|
59
|
+
cutoff_current: Optional[float] = None
|
|
60
|
+
cutoff_time: Optional[float] = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class CP(_Step):
|
|
65
|
+
"""Constant-power step."""
|
|
66
|
+
power: float # watts; positive = charge
|
|
67
|
+
cutoff_voltage: Optional[float] = None
|
|
68
|
+
cutoff_time: Optional[float] = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class CR(_Step):
|
|
73
|
+
"""Constant-resistance step."""
|
|
74
|
+
resistance: float # ohms
|
|
75
|
+
cutoff_voltage: Optional[float] = None
|
|
76
|
+
cutoff_time: Optional[float] = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class Rest(_Step):
|
|
81
|
+
"""Open-circuit rest step."""
|
|
82
|
+
duration: float # seconds
|
|
83
|
+
cutoff_voltage: Optional[float] = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class Loop(_Step):
|
|
88
|
+
"""Repeat all preceding steps N times."""
|
|
89
|
+
cycles: int
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class EIS(_Step):
|
|
94
|
+
"""Electrochemical Impedance Spectroscopy sweep."""
|
|
95
|
+
freq_start: float = 100_000.0 # Hz
|
|
96
|
+
freq_end: float = 0.01 # Hz
|
|
97
|
+
amplitude: float = 0.01 # A (perturbation amplitude)
|
|
98
|
+
points_per_decade: int = 10
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ------------------------------------------------------------------
|
|
102
|
+
# Recipe container
|
|
103
|
+
# ------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class Recipe:
|
|
107
|
+
"""
|
|
108
|
+
An ordered sequence of cycler steps.
|
|
109
|
+
|
|
110
|
+
Methods are chainable::
|
|
111
|
+
|
|
112
|
+
recipe = Recipe().add(CC(...)).add(CV(...)).add(Rest(...))
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(self, name: str = "Untitled Recipe") -> None:
|
|
116
|
+
self.name = name
|
|
117
|
+
self._steps: List[_Step] = []
|
|
118
|
+
|
|
119
|
+
def add(self, step: _Step) -> "Recipe":
|
|
120
|
+
self._steps.append(step)
|
|
121
|
+
return self
|
|
122
|
+
|
|
123
|
+
def to_dict(self) -> dict:
|
|
124
|
+
return {
|
|
125
|
+
"name": self.name,
|
|
126
|
+
"steps": [s.to_dict() for s in self._steps],
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
def __repr__(self) -> str:
|
|
130
|
+
return f"Recipe(name={self.name!r}, steps={len(self._steps)})"
|
microcycler/streaming.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Async UDP telemetry streaming.
|
|
3
|
+
|
|
4
|
+
The cycler broadcasts telemetry as UDP JSON packets on port 5026.
|
|
5
|
+
Use CyclerStream to consume these packets as an async iterator, or
|
|
6
|
+
use discover() to find cyclers on the local network.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
import socket
|
|
14
|
+
from typing import AsyncIterator, List
|
|
15
|
+
|
|
16
|
+
from .types import CyclerTelemetry
|
|
17
|
+
|
|
18
|
+
_UDP_PORT = 5026
|
|
19
|
+
_BROADCAST_ADDR = "255.255.255.255"
|
|
20
|
+
_DISCOVERY_TIMEOUT = 3.0
|
|
21
|
+
_MAX_PACKET = 4096
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _UDPProtocol(asyncio.DatagramProtocol):
|
|
25
|
+
def __init__(self, queue: asyncio.Queue) -> None:
|
|
26
|
+
self._queue = queue
|
|
27
|
+
|
|
28
|
+
def datagram_received(self, data: bytes, addr: tuple) -> None:
|
|
29
|
+
self._queue.put_nowait((data, addr))
|
|
30
|
+
|
|
31
|
+
def error_received(self, exc: Exception) -> None:
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CyclerStream:
|
|
36
|
+
"""
|
|
37
|
+
Async iterator that yields CyclerTelemetry from UDP broadcast packets.
|
|
38
|
+
|
|
39
|
+
Usage::
|
|
40
|
+
|
|
41
|
+
async for telemetry in CyclerStream():
|
|
42
|
+
print(telemetry.voltage, telemetry.current)
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, host: str = "", port: int = _UDP_PORT) -> None:
|
|
46
|
+
self._host = host
|
|
47
|
+
self._port = port
|
|
48
|
+
self._transport: asyncio.BaseTransport | None = None
|
|
49
|
+
self._queue: asyncio.Queue = asyncio.Queue()
|
|
50
|
+
|
|
51
|
+
async def __aenter__(self) -> "CyclerStream":
|
|
52
|
+
loop = asyncio.get_running_loop()
|
|
53
|
+
self._transport, _ = await loop.create_datagram_endpoint(
|
|
54
|
+
lambda: _UDPProtocol(self._queue),
|
|
55
|
+
local_addr=(self._host, self._port),
|
|
56
|
+
allow_broadcast=True,
|
|
57
|
+
)
|
|
58
|
+
return self
|
|
59
|
+
|
|
60
|
+
async def __aexit__(self, *_) -> None:
|
|
61
|
+
if self._transport:
|
|
62
|
+
self._transport.close()
|
|
63
|
+
|
|
64
|
+
def __aiter__(self) -> AsyncIterator[CyclerTelemetry]:
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
async def __anext__(self) -> CyclerTelemetry:
|
|
68
|
+
data, _ = await self._queue.get()
|
|
69
|
+
return CyclerTelemetry.from_dict(json.loads(data))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def discover(
|
|
73
|
+
timeout: float = _DISCOVERY_TIMEOUT,
|
|
74
|
+
port: int = _UDP_PORT,
|
|
75
|
+
) -> List[str]:
|
|
76
|
+
"""
|
|
77
|
+
Send a broadcast discovery ping and collect responding cycler IPs.
|
|
78
|
+
|
|
79
|
+
Returns a list of IP address strings.
|
|
80
|
+
|
|
81
|
+
Usage::
|
|
82
|
+
|
|
83
|
+
hosts = await discover()
|
|
84
|
+
# ['192.168.1.100', '192.168.1.101']
|
|
85
|
+
"""
|
|
86
|
+
found: dict[str, bool] = {}
|
|
87
|
+
queue: asyncio.Queue = asyncio.Queue()
|
|
88
|
+
|
|
89
|
+
loop = asyncio.get_running_loop()
|
|
90
|
+
|
|
91
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
92
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
|
93
|
+
sock.setblocking(False)
|
|
94
|
+
|
|
95
|
+
transport, _ = await loop.create_datagram_endpoint(
|
|
96
|
+
lambda: _UDPProtocol(queue),
|
|
97
|
+
sock=sock,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
transport.sendto(b"DISC?\n", (_BROADCAST_ADDR, port))
|
|
102
|
+
deadline = loop.time() + timeout
|
|
103
|
+
while True:
|
|
104
|
+
remaining = deadline - loop.time()
|
|
105
|
+
if remaining <= 0:
|
|
106
|
+
break
|
|
107
|
+
try:
|
|
108
|
+
_, addr = await asyncio.wait_for(queue.get(), timeout=remaining)
|
|
109
|
+
found[addr[0]] = True
|
|
110
|
+
except asyncio.TimeoutError:
|
|
111
|
+
break
|
|
112
|
+
finally:
|
|
113
|
+
transport.close()
|
|
114
|
+
|
|
115
|
+
return list(found.keys())
|
microcycler/types.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared data types mirroring the dashboard's TypeScript definitions in
|
|
3
|
+
dashboard/app/lib/types.ts.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Tuple
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CyclerState(str, Enum):
|
|
14
|
+
IDLE = "IDLE"
|
|
15
|
+
CHARGING = "CHARGING"
|
|
16
|
+
DISCHARGING = "DISCHARGING"
|
|
17
|
+
REST = "REST"
|
|
18
|
+
COMPLETE = "COMPLETE"
|
|
19
|
+
ERROR = "ERROR"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class CyclerLimits:
|
|
24
|
+
voltage_upper: float
|
|
25
|
+
voltage_lower: float
|
|
26
|
+
current_upper: float
|
|
27
|
+
temperature_upper: float
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class CyclerTelemetry:
|
|
32
|
+
device_id: str
|
|
33
|
+
firmware: str
|
|
34
|
+
hardware: str
|
|
35
|
+
state: CyclerState
|
|
36
|
+
voltage: float
|
|
37
|
+
current: float
|
|
38
|
+
temperature: float
|
|
39
|
+
step: int
|
|
40
|
+
cycle: int
|
|
41
|
+
elapsed: float
|
|
42
|
+
capacity_mah: float
|
|
43
|
+
energy_mwh: float
|
|
44
|
+
limits: CyclerLimits
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_dict(cls, data: dict) -> "CyclerTelemetry":
|
|
48
|
+
limits = CyclerLimits(**data.pop("limits"))
|
|
49
|
+
return cls(**data, limits=limits)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class CyclerHealth:
|
|
54
|
+
device_id: str
|
|
55
|
+
board_temp: float
|
|
56
|
+
cpu_load_core1: float
|
|
57
|
+
cpu_load_core2: float
|
|
58
|
+
heatsink_temps: Tuple[float, float, float, float]
|
|
59
|
+
main_current_draw: float
|
|
60
|
+
fan_current_draw: float
|
|
61
|
+
die_temp: float
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_dict(cls, data: dict) -> "CyclerHealth":
|
|
65
|
+
data["heatsink_temps"] = tuple(data["heatsink_temps"])
|
|
66
|
+
return cls(**data)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: microcycler
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for the Microcycler battery testing platform
|
|
5
|
+
Project-URL: Homepage, https://github.com/your-org/microcycler
|
|
6
|
+
Project-URL: Documentation, https://docs.microcycler.io
|
|
7
|
+
Project-URL: Issues, https://github.com/your-org/microcycler/issues
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: EIS,battery,cycler,electrochemistry,lab,testing
|
|
10
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Chemistry
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Provides-Extra: all
|
|
22
|
+
Requires-Dist: numpy>=1.24; extra == 'all'
|
|
23
|
+
Requires-Dist: pandas>=2.0; extra == 'all'
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: build; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
28
|
+
Requires-Dist: twine; extra == 'dev'
|
|
29
|
+
Provides-Extra: numpy
|
|
30
|
+
Requires-Dist: numpy>=1.24; extra == 'numpy'
|
|
31
|
+
Provides-Extra: pandas
|
|
32
|
+
Requires-Dist: pandas>=2.0; extra == 'pandas'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# Microcycler Python SDK
|
|
36
|
+
|
|
37
|
+
Python SDK for the [Microcycler](https://github.com/your-org/microcycler) battery testing platform.
|
|
38
|
+
|
|
39
|
+
> **Status:** Pre-alpha — API subject to change.
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install microcycler
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Quickstart
|
|
48
|
+
|
|
49
|
+
### Connect and read telemetry
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
import asyncio
|
|
53
|
+
from microcycler import Cycler
|
|
54
|
+
|
|
55
|
+
async def main():
|
|
56
|
+
async with Cycler("192.168.1.100") as cycler:
|
|
57
|
+
print(await cycler.identify())
|
|
58
|
+
t = await cycler.get_telemetry()
|
|
59
|
+
print(f"State: {t.state}")
|
|
60
|
+
print(f"Voltage: {t.voltage:.3f} V")
|
|
61
|
+
print(f"Current: {t.current:.3f} A")
|
|
62
|
+
print(f"Temp: {t.temperature:.1f} °C")
|
|
63
|
+
print(f"Capacity: {t.capacity_mah:.1f} mAh")
|
|
64
|
+
|
|
65
|
+
asyncio.run(main())
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Discover cyclers on the network
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from microcycler.streaming import discover
|
|
72
|
+
|
|
73
|
+
hosts = asyncio.run(discover())
|
|
74
|
+
print(hosts) # ['192.168.1.100', '192.168.1.101']
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Stream live telemetry via UDP
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from microcycler.streaming import CyclerStream
|
|
81
|
+
|
|
82
|
+
async def monitor():
|
|
83
|
+
async with CyclerStream() as stream:
|
|
84
|
+
async for telemetry in stream:
|
|
85
|
+
print(telemetry.voltage, telemetry.current)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Build and run a recipe
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from microcycler import Cycler
|
|
92
|
+
from microcycler.recipes import Recipe, CC, CV, Rest, Loop
|
|
93
|
+
|
|
94
|
+
recipe = (
|
|
95
|
+
Recipe(name="Standard Formation")
|
|
96
|
+
.add(CC(current=0.1, cutoff_voltage=4.2))
|
|
97
|
+
.add(CV(voltage=4.2, cutoff_current=0.02))
|
|
98
|
+
.add(Rest(duration=1800))
|
|
99
|
+
.add(CC(current=-0.2, cutoff_voltage=2.8))
|
|
100
|
+
.add(Rest(duration=1800))
|
|
101
|
+
.add(Loop(cycles=3))
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
async def run():
|
|
105
|
+
async with Cycler("192.168.1.100") as cycler:
|
|
106
|
+
await cycler.load_recipe(recipe)
|
|
107
|
+
await cycler.start()
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Recipe steps
|
|
111
|
+
|
|
112
|
+
| Step | Description |
|
|
113
|
+
|------|-------------|
|
|
114
|
+
| `CC(current, cutoff_voltage, cutoff_time, cutoff_capacity)` | Constant current |
|
|
115
|
+
| `CV(voltage, cutoff_current, cutoff_time)` | Constant voltage |
|
|
116
|
+
| `CP(power, cutoff_voltage, cutoff_time)` | Constant power |
|
|
117
|
+
| `CR(resistance, cutoff_voltage, cutoff_time)` | Constant resistance |
|
|
118
|
+
| `Rest(duration)` | Open-circuit rest |
|
|
119
|
+
| `EIS(freq_start, freq_end, amplitude, points_per_decade)` | Impedance sweep |
|
|
120
|
+
| `Loop(cycles)` | Repeat preceding steps N times |
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
microcycler/__init__.py,sha256=ht91pj4HRWhdDR4ncKtT0noHsnLrza8Nk6V3hCPdxpM,1176
|
|
2
|
+
microcycler/client.py,sha256=oJfSlGHyugcXt8jg79zDRoaEQKRf0Kvq1xa2oHzkzoM,6237
|
|
3
|
+
microcycler/recipes.py,sha256=eqfTxkFNzN_iDIdUbVU3kRA98eXNR8T0pdMgFdCLdCo,3515
|
|
4
|
+
microcycler/streaming.py,sha256=HRhgxV19IgMMmQkrG2fwW2OefuzMWfMFGPsY_hsu5m0,3245
|
|
5
|
+
microcycler/types.py,sha256=QI_US2-M2mMCGErYM_vTqNO8uJYHoW6xC6yZcbtuVK8,1488
|
|
6
|
+
microcycler-0.1.0.dist-info/METADATA,sha256=BkBojCheVRg7zfNRJozDRyMNeBm2dJqyeWCEvUYOtNc,3638
|
|
7
|
+
microcycler-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
8
|
+
microcycler-0.1.0.dist-info/RECORD,,
|