Metadata-Version: 2.5
Name: microcycler
Version: 0.1.6
Summary: Python SDK for the Microcycler battery testing platform
Project-URL: Homepage, https://github.com/your-org/microcycler
Project-URL: Documentation, https://docs.microcycler.io
Project-URL: Issues, https://github.com/your-org/microcycler/issues
License: MIT
Keywords: EIS,battery,cycler,electrochemistry,lab,testing
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-Python: >=3.9
Provides-Extra: all
Requires-Dist: cryptography>=41; extra == 'all'
Requires-Dist: numpy>=1.24; extra == 'all'
Requires-Dist: pandas>=2.0; extra == 'all'
Provides-Extra: dev
Requires-Dist: build; extra == 'dev'
Requires-Dist: pytest; extra == 'dev'
Requires-Dist: pytest-asyncio; extra == 'dev'
Requires-Dist: pytest-html; extra == 'dev'
Requires-Dist: twine; extra == 'dev'
Provides-Extra: numpy
Requires-Dist: numpy>=1.24; extra == 'numpy'
Provides-Extra: pandas
Requires-Dist: pandas>=2.0; extra == 'pandas'
Provides-Extra: remote
Requires-Dist: cryptography>=41; extra == 'remote'
Provides-Extra: signing
Requires-Dist: cryptography>=41; extra == 'signing'
Description-Content-Type: text/markdown

# 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
```

## 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. |
| `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 |
| `Loop(cycles)` | Repeat preceding steps N times |
| `ACRipple(duration, dc_current, ac_amplitude, ac_freq, cutoff_temp)` | AC ripple excitation for calorimetry / thermal characterisation |

## 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()
```
