Metadata-Version: 2.4
Name: microcycler
Version: 0.1.39
Classifier: Development Status :: 2 - Pre-Alpha
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Scientific/Engineering :: Chemistry
Classifier: Topic :: Scientific/Engineering :: Physics
Requires-Dist: cryptography>=41 ; extra == 'all'
Requires-Dist: numpy>=1.24 ; extra == 'dev'
Requires-Dist: scipy>=1.10 ; extra == 'dev'
Requires-Dist: pytest ; extra == 'dev'
Requires-Dist: pytest-asyncio ; extra == 'dev'
Requires-Dist: pytest-html ; extra == 'dev'
Requires-Dist: maturin ; extra == 'dev'
Requires-Dist: twine ; extra == 'dev'
Requires-Dist: cryptography>=41 ; extra == 'signing'
Requires-Dist: numpy>=1.24 ; extra == 'twin'
Requires-Dist: scipy>=1.10 ; extra == 'twin'
Provides-Extra: all
Provides-Extra: dev
Provides-Extra: signing
Provides-Extra: twin
Summary: Python SDK for the Microcycler battery testing platform
Keywords: battery,cycler,electrochemistry,EIS,testing,lab
License: MIT
Requires-Python: >=3.9
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
Project-URL: Documentation, https://docs.microcycler.io
Project-URL: Homepage, https://github.com/your-org/microcycler
Project-URL: Issues, https://github.com/your-org/microcycler/issues

# Microcycler Python SDK

Python SDK for the [Microcycler](https://github.com/your-org/microcycler) battery testing platform.

> **Status:** Pre-alpha — API subject to change.

## Installation

```bash
pip install microcycler
```

The package is a thin Python layer over a Rust core (`core/` in the repository: the same code the Microcycler dashboard and the headless agent run), shipped as one prebuilt wheel per platform (Windows, macOS, Linux; Python 3.9+). `pip install microcycler` needs no compiler. Installing from a source checkout (`pip install -e sdk`) builds the core and needs a Rust toolchain ([rustup.rs](https://rustup.rs)).

## Two ways to run an experiment

- **Standard**: build a recipe from steps (CC, CV, rest, loop, EIS, …) and store it on the cycler. It runs on the cycler by itself, so the computer can be switched off (the Recipes section below).
- **Dynamic**: a Python script on a host computer drives the cycler directly over TCP/UDP, for a test that does not fit the building blocks. The computer must stay connected: if the script dies or the host goes quiet for 30 s, the cycler releases the output on its own. The cycler reports its state as `DYNAMIC` while a script holds it. Scripts are never uploaded to a cycler; one process runs per cycler:

```
microcycler-dynamic run my_test.py --cycler 192.168.1.50 --cycler 192.168.1.51 --cell-id NMC-A1-001 --cell-id NMC-A1-002 --run-id run-7
```

```python
from microcycler.dynamic import context

async def main():
    ctx = context()                                   # the cycler this process drives, and the test's metadata
    async with ctx.cycler() as c, c.dynamic(voltage_upper=4.2, voltage_lower=3.0, current_upper=0.5) as out:
        await out.set_current(0.2)
        ...
```

## C-rates and the Capacity Check

Wherever a step takes a current you can give amps or a C-rate: `CC(current=0.25)` is 250 mA, `CC(current=C(1))` is 1 C (250 mA for a 250 mAh cell) and `C(0.25)` is a quarter of that. The cycler turns a C-rate into amps when the test starts, from the capacity you give it, so one recipe serves cells of any size; `start_recipe` refuses (before sending anything) a recipe that uses one without `capacity_mah`:

```python
from microcycler.recipes import C, CC, CapacityCheck, Recipe

recipe = Recipe("Characterise", steps=[CapacityCheck(charge_current=C(0.25), discharge_current=C(0.25), cutoff_current=C(0.05)), CC(current=C(1), cutoff_voltage_high=4.2)])
await cycler.start_recipe("characterise.json", cell_id="NMC-A1-001", run_id="run-7", content=recipe,
                          capacity_mah=250, v_min=2.5, v_max=4.2)
```

`CapacityCheck(charge_current=..., discharge_current=..., cutoff_current=...)` charges to the top voltage, holds until the current tapers to the cutoff (default C/20), rests, and discharges to the bottom voltage; the discharge counter starts from zero, so what it delivers is the result. The capacity it delivers is saved on the cycler's SD card as one small artifact file (`<result file>_CAP_sNN.csv`), so the result is kept whether or not a computer is connected; the dashboard adds it to the cell's record when it next sees the cycler. Leave `voltage_max` / `voltage_min` out to use the cell's own window (`v_min` / `v_max`).

## Recipes on the cycler

A cycler stores recipes on its SD card (`recipes/<name>.json`); upload one once and any client can start it by name:

```python
name = await cycler.upload_recipe(Recipe("NMC formation", steps=[...]))     # -> "NMC_formation.json"
print(await cycler.recipe_files())
await cycler.start_recipe(name, cell_id="NMC-A1-001", run_id="run-7", test_type="formation")
```

`cell_id`, `run_id` and `test_type` (1-24 characters from `A-Za-z0-9._-`) name the data files the test writes under `data/` on the card. The dashboard and the phone start recipes the same way.

## Uplink agent

Set up a headless computer (for example a Linux box in the lab) in two steps, with no browser and nothing to copy:

```
pip install microcycler
microcycler-agent enroll --install-service     # prints a code like ABCD-EFGH
```

An admin enters the code in the dashboard (Settings → Organization → Uplink → Add a computer) after checking the fingerprint, and the computer then uploads this network's licensed cyclers to your organization as a background service. Whether uplink is persistent is explicit: `enroll` and `import` ask "Keep uplink running in the background and start it at boot?" (or pass `--service` / `--no-service`), `microcycler-agent run` says on its first line whether it is running in the foreground (stops when the terminal closes) or as a service, every status line carries `mode` and `persistent`, and `microcycler-agent status` shows the service state. Only one agent runs per computer: starting a second one (another terminal, the service, or the dashboard) is refused with who already has it. `microcycler-agent run` runs it in the foreground instead; `import <credential>` is the offline alternative for a credential file made in the dashboard. Local telemetry needs UDP 5001/5002 open on a host firewall (`sudo ufw allow in proto udp from 169.254.0.0/16 to any port 5001,5002`, adjusting the source range to your cyclers' subnet).

## Quickstart

### Connect and read telemetry

```python
import asyncio
from microcycler import Cycler

async def main():
    async with Cycler("192.168.1.100") as cycler:
        print(await cycler.identify())
        t = await cycler.get_telemetry()
        print(f"State:    {t.state}")
        print(f"Voltage:  {t.voltage:.3f} V")
        print(f"Current:  {t.current:.3f} A")
        print(f"Temp:     {t.temperature:.1f} °C")
        print(f"Capacity: {t.capacity_mah:.1f} mAh")

asyncio.run(main())
```

### Discover cyclers on the network

```python
import asyncio
from microcycler import Cycler, discover

async def main():
    # Listens for the discovery beacon on UDP 5002. The beacon is periodic
    # and unsolicited, so this is a collection window, not a deadline —
    # allow at least one beacon period (10 s by default).
    for dev in await discover():
        print(dev)                      # HADES-042 @ 169.254.10.5:5000 (standby)
        async with Cycler(dev.host, port=dev.port) as cycler:
            print(await cycler.get_life())

asyncio.run(main())
```

### Stream live telemetry via UDP

```python
from microcycler.streaming import CyclerStream

async def monitor():
    async with CyclerStream() as stream:
        async for telemetry in stream:
            print(telemetry.voltage, telemetry.current)
```

### Build and run a recipe

```python
from microcycler import Cycler
from microcycler.recipes import Recipe, CC, CV, Rest, Loop

recipe = (
    Recipe(name="Standard Formation")
    .add(CC(current=0.1, cutoff_voltage_high=4.2))
    .add(CV(voltage=4.2, limit_current_high=0.1, cutoff_current_low=0.02))
    .add(Rest(duration=1800))
    .add(CC(current=-0.2, cutoff_voltage_low=2.8))
    .add(Rest(duration=1800))
    .add(Loop(cycles=3))
)

async def run():
    async with Cycler("192.168.1.100") as cycler:
        await cycler.load_recipe(recipe)
        await cycler.start()
```

## Recipe steps

| Step | Description |
|------|-------------|
| `CC(current, cutoff_voltage_high, cutoff_voltage_low, cutoff_time, cutoff_capacity)` | Constant current. `current` is signed: positive = charge, negative = discharge. `cutoff_capacity` (mAh) ends the step after that much charge in either direction; CV, CP and CR take it too. |
| `CV(voltage, limit_current_high, cutoff_current_low, cutoff_time)` | Constant voltage |
| `CP(power, cutoff_voltage, cutoff_time)` | Constant power |
| `CR(resistance, cutoff_voltage, cutoff_time)` | Constant resistance |
| `Rest(duration)` | Open-circuit rest |
| `EIS(freq_start, freq_end, amplitude, points_per_decade)` | Impedance sweep (`freq_start` is the lower frequency) |
| `Loop(cycles, jump_to)` | Return to step `jump_to` (1-based) `cycles` more times |
| `GoToSoc(target_soc, current, soc, capacity_mah, ...)` | Take the cell to a state of charge by counting mAh from the running state of charge (anchored by a Capacity Check, or at 0 / 100 %); needs the cell's capacity |
| `CapacityCheck(charge_current, discharge_current, ...)` | Charge, rest and discharge that measure the capacity |
| `ACRipple(duration, dc_current, ac_amplitude, ac_freq, cutoff_temp)` | AC ripple excitation for calorimetry / thermal characterisation |
| `Pulse(amplitude, width, lead, tail, current)` | A current pulse on a DC base (edges exact to 20 µs) |
| `Gitt(current, pulse, rest, rest_dvdt, pulses, ...)` | Galvanostatic intermittent titration: pulses and settling rests, recorded in a GITT file; analysed with `microcycler.analysis.gitt` (OCV, IR drop, diffusion coefficients) |
| `Hppc(current, charge_current, soc_current, pulse, rest, soc_step, soc_rest, levels, ...)` | Hybrid pulse power characterisation: pulse sets at stepped states of charge, recorded in an HPPC file; analysed with `microcycler.analysis.hppc` (resistance and pulse power against state of charge). Needs the cell's capacity. |
| `Pitt(voltage_start, voltage_end, voltage_step, limit_current_high, cutoff_current_low, ...)` | Staircase of constant-voltage holds, each until its current has decayed, recorded in a PITT file; analysed with `microcycler.analysis.pitt` (incremental capacity, diffusion time constants) |
| `Profile(file, scale, repeat, ...)` | Replays a current or power time series stored on the cycler's SD card (`profiles/<file>`); `upload_profile`, `profile_files`, `profile_info`, `delete_profile`, and `microcycler.profiles` build the files |

Any step takes `.with_capture(duration, start, rate)`: a window of the control loop's voltage and current at 50 kS/s, saved on the cycler as a `SCOPE` file. See the [recipe steps](../documentation/docs/sdk/recipes.md) page.

## License

MIT

---

## Lifetime wear counters

Every board keeps an odometer in its EEPROM. Three independent axes, because a
unit can be worn out along any one of them while the others look fine:
**mechanical** (relay actuations), **calendar** (powered time) and
**electrical** (charge through the output).

```python
from microcycler import Cycler

async with Cycler("192.168.1.200") as cycler:
    life = await cycler.get_life()

    print(f"boots            {life.boot_count}")
    print(f"powered          {life.powered_hours:,.0f} h")
    print(f"throughput       {life.throughput_ah:,.1f} Ah")
    print(f"MAIN relay       {life.relay_main:,} closings")
    print(f"FAN relay        {life.relay_fan:,} closings")

    if not life.durable:
        print("WARNING: counters are not reaching the EEPROM")
```

These belong to the physical board, not to your database, so they survive a
reinstall, an MCU swap, or the unit moving to another lab. There is
deliberately **no way to reset them** — an odometer that can be wound back is
not evidence of anything.

`life.durable` (i.e. `write_errors == 0`) is the field worth alerting on. A
unit with a dead EEPROM still counts perfectly well in RAM and reports entirely
plausible numbers; nothing else distinguishes a kept odometer from one that is
lost at every power cut.

Counters are flushed at most once every few minutes to spare the part, so up to
that much accrued wear is in RAM only. Before deliberately powering a unit
down:

```python
await cycler.flush_life()
```

