# cellpy-simple-gui — full documentation

> A desktop and self-hosted GUI for exploring battery cell data with cellpy, plus the documentation for building your own cellpy application.

Concatenated from the repository. Each document is preceded by its path.
Generated by tools/gen_llms_txt.py — do not edit.


==============================================================================
FILE: README.md
==============================================================================

<p align="center">
  <a href="https://github.com/cellpy/cellpy-simple-gui">
    <img src="assets/cellpy-icon-long.svg" width="300" alt="cellpy" />
  </a>
</p>

<h1 align="center">cellpy simple gui</h1>

<p align="center"><b>Battery cell data, explored — a small desktop app built on <a href="https://github.com/jepegit/cellpy">cellpy</a> (≥ 2.1).</b></p>

<p align="center">
  Load <code>.cellpy</code> files, raw instrument exports or whole batch journals, compare cells
  with cellpy's own summary and cycle plots, group and average them, save the lot as a
  portable project, and export the numbers and figures.
</p>

<p align="center">
  <a href="https://pypi.org/project/cellpy-simple-gui/"><img alt="PyPI" src="https://img.shields.io/pypi/v/cellpy-simple-gui?color=2f6fde&label=PyPI" /></a>
  <a href="https://github.com/cellpy/cellpy-simple-gui/releases/latest"><img alt="Latest release (Windows installer)" src="https://img.shields.io/github/v/release/cellpy/cellpy-simple-gui?color=1f8f6b&label=Windows%20installer&logo=windows&logoColor=white" /></a>
  <img alt="Python ≥ 3.13" src="https://img.shields.io/badge/python-%E2%89%A5%203.13-3776ab?logo=python&logoColor=white" />
  <a href="https://github.com/jepegit/cellpy"><img alt="cellpy ≥ 2.1" src="https://img.shields.io/badge/cellpy-%E2%89%A5%202.1-1f8f6b" /></a>
  <a href="https://github.com/cellpy/cellpy-simple-gui/actions/workflows/essential-tests.yml"><img alt="Essential tests" src="https://github.com/cellpy/cellpy-simple-gui/actions/workflows/essential-tests.yml/badge.svg" /></a>
  <a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-7a7a7a" /></a>
</p>

<p align="center">
  <a href="https://github.com/cellpy/cellpy-simple-gui/releases/latest"><b>Download</b></a> ·
  <a href="#install"><b>Install</b></a> ·
  <a href="#quick-start-from-a-clone"><b>Quick start</b></a> ·
  <a href="#features"><b>Features</b></a> ·
  <a href="#run-it-as-a-server"><b>Server</b></a> ·
  <a href="#building-your-own-cellpy-app"><b>Build your own</b></a> ·
  <a href="docs/"><b>Docs</b></a>
</p>

> **Get it:** Windows users — grab the installer from the
> [**latest release**](https://github.com/cellpy/cellpy-simple-gui/releases/latest)
> (no Python, no admin; the [newest build from `main`](https://github.com/cellpy/cellpy-simple-gui/releases/tag/continuous)
> is there too). Everyone else — `uv tool install "cellpy-simple-gui[desktop]"`.
> Details under [Install](#install).

<p align="center">
  <img src="docs/img/demo.gif" width="100%" alt="Demo: load the bundled demo cells, group them, open the cell explorer, import a raw Neware file through the Add cells dialog, and switch to the light theme" />
</p>

<p align="center"><sub>
  Load the demo cells → group them → cell explorer → import a raw Neware file through <b>Add cells…</b> → back to the summary → light theme.
  Everything runs locally: a <a href="https://fastapi.tiangolo.com/">FastAPI</a> backend inside a native
  <a href="https://pywebview.flowrl.com/">pywebview</a> window (or your browser).
</sub></p>

## Some examples

<table>
  <tr>
    <td colspan="2" align="center">
      <img src="docs/img/summary_collect.png" alt="Cycle summary — capacity and coulombic efficiency for three grouped cells" />
      <br /><sub><b>Cycle summary.</b> cellpy's <code>collect_summaries</code> across every selected cell — capacity + CE here, with a plot-type selector, gravimetric / areal / absolute basis and independent or shared y-scales.</sub>
    </td>
  </tr>
  <tr>
    <td width="50%" align="center">
      <img src="docs/img/add_cells_staged.png" alt="Add cells dialog with a staged list of files to load" />
      <br /><sub><b>Add cells…</b> Drop, browse, paste paths or glob patterns, or find files in a remote folder — everything lands in one <em>staged list</em> you review before a single button loads it.</sub>
    </td>
    <td width="50%" align="center">
      <img src="docs/img/add_cells_raw.png" alt="Add cells dialog, raw instrument files tab with Neware preset" />
      <br /><sub><b>Raw instrument files.</b> Instruments are discovered from cellpy at runtime; set mass, area, nominal capacity and cycle mode once and import.</sub>
    </td>
  </tr>
  <tr>
    <td width="50%" align="center">
      <img src="docs/img/cell_collect.png" alt="Cell explorer — voltage versus capacity curves for selected cycles" />
      <br /><sub><b>Cell explorer.</b> Voltage–capacity curves for any set of cycles, with per-cell metric tiles.</sub>
    </td>
    <td width="50%" align="center">
      <img src="docs/img/cell_dqdv.png" alt="Cell explorer showing incremental capacity (dQ/dV) curves" />
      <br /><sub><b>dQ/dV.</b> The same cycles as incremental capacity (or differential voltage), charge / discharge / both.</sub>
    </td>
  </tr>
  <tr>
    <td width="50%" align="center">
      <img src="docs/img/cycles_per_cell.png" alt="Cycles collector — cycle curves for every selected cell, one panel per cell" />
      <br /><sub><b>Cycles collector.</b> The same three curve types across <em>every</em> selected cell, laid out per cycle, per cell, or as a film plot.</sub>
    </td>
    <td width="50%" align="center">
      <img src="docs/img/summary_grouped_light.png" alt="Grouped cycle summary in the light theme with a mean ± std spread band" />
      <br /><sub><b>Groups &amp; light theme.</b> Average cells per group with a mean ± std spread band — here with the light theme on.</sub>
    </td>
  </tr>
  <tr>
    <td width="50%" align="center">
      <img src="docs/img/manage_cells.png" alt="Manage cells modal — filter, sort, select by group, remove" />
      <br /><sub><b>Manage cells.</b> Rename, regroup, select by group, filter and sort the journal of loaded cells.</sub>
    </td>
    <td width="50%" align="center">
      <img src="docs/img/projects_dark.png" alt="Projects panel — save and reopen portable project folders" />
      <br /><sub><b>Projects.</b> Save the loaded set plus grouping, labels and selection to a portable folder; reopen it later.</sub>
    </td>
  </tr>
  <tr>
    <td colspan="2" align="center">
      <img src="docs/img/plot_types.png" alt="Four summary plot types: capacity, coulombic efficiency, C-rate and internal resistance" />
      <br /><sub><b>Plot types.</b> Capacity, coulombic efficiency, C-rate, internal resistance — plus cumulated CE, end voltages and capacity loss; <a href="#developer-mode">developer mode</a> unlocks every family cellpy registers.</sub>
    </td>
  </tr>
</table>

---

## Features

- **Demo cells** — three bundled examples, plus bundled raw-file demos.
- **`.cellpy` and legacy `.h5` files.**
- **Raw instrument files** — Arbin `.res`, Maccor (text), Neware, PEC, and others. Mass, area, nominal capacity, cycle mode.
- **Add cells** — drop, browse, upload, pasted paths, globs, or a remote folder. Staged list before load. Batch journals (`.json`).
- **Cycle summary** — capacity, CE, cumulated CE, end voltages, internal resistance, C-rate, capacity loss. Gravimetric, areal, or absolute. Group mean ± std. Shared or independent y-scales.
- **Cell explorer** — voltage–capacity, dQ/dV, dV/dQ. Compare cells, overlaid or side by side.
- **Cycles collector** — per cycle, per cell, or film plot.
- **Cell list** — rename, group, select, filter, sort, remove.
- **Projects** — save and reopen the loaded cells, grouping, labels, and selection. Unsaved edits show as `name*`.
- **Export** — CSV, Excel, Parquet, JSON; figures as PNG, SVG, PDF.
- **Light and dark themes.**
- **Background loading** with progress.
- **Instruments** from the installed cellpy, including each loader's sub-models.
- **Developer mode** — every summary family cellpy registers, raw traces, diagnostics (`run --dev`).
- **Logging** — `CSG_LOG_LEVEL`, default `INFO`.

## Install

Requires **Python ≥ 3.13**. With [uv](https://docs.astral.sh/uv/):

```bash
uv tool install "cellpy-simple-gui[desktop]"
cellpy-simple-gui
```

Or run it once without installing anything:

```bash
uvx --from "cellpy-simple-gui[desktop]" cellpy-simple-gui
```

pipx works too: `pipx install "cellpy-simple-gui[desktop]"`.

The `[desktop]` extra is what gives you the **native window**. Leave it off and
you get a fully working app that opens in your browser instead — which is what a
server wants, and why it is an extra rather than a dependency.

Two heavier routes, if you would rather not have Python in the picture:

- **Windows installer** — 178 MB, no admin, Start-menu entry. Download the
  [latest release](https://github.com/cellpy/cellpy-simple-gui/releases/latest),
  or the [newest build from `main`](https://github.com/cellpy/cellpy-simple-gui/releases/tag/continuous)
  (rebuilt on every code change). Each installer is also a `.zip` of that same
  file, for machines that block an `.exe` download. See [`docs/windows-installer.md`](docs/windows-installer.md).
- **Container** — `docker compose up`. See [`docs/deployment.md`](docs/deployment.md).

---

## Quick start (from a clone)

Requires **Python ≥ 3.13** and [uv](https://docs.astral.sh/uv/).

```bash
uv sync --extra desktop
```

The native window lives in the `desktop` extra so a served instance need not
install GUI libraries. Plain `uv sync` gives you a fully working app that opens
in your browser instead.

Then from this folder:

```bash
run                 # Windows (cmd / PowerShell)
./run               # macOS / Linux / Git Bash
```

That opens the app in a native desktop window. Prefer your normal browser?

```bash
run --server              # local server + browser tab
run --server --no-open    # headless: just serve
```

The helpers use `uv run --extra export --extra desktop`, so figure export and the
native window both work. Equivalent without them:
`uv run --extra export --extra desktop cellpy-simple-gui` (same flags).
Then click **Load demo cells** and explore.

### Build the Windows installer

```powershell
pwsh packaging/build_installer.ps1
```

Produces a ~178 MB per-user installer: no admin, Start-menu entry, clean
uninstall that leaves your projects alone. It is **unsigned**, so SmartScreen
will warn on first run — [`docs/windows-installer.md`](docs/windows-installer.md)
explains exactly what you will see, why, and what fixing it costs, along with
where the app writes its logs when something goes wrong.

Cutting a release of any of the three artefacts:
[`docs/releasing.md`](docs/releasing.md).

### Run it as a server

```bash
docker compose up --build      # or: docker build -t cellpy-simple-gui .
```

One container serves **one person** — the library, the job manager and cellpy's
config session are process-global, by design. The per-launch token is **not**
authentication: put it behind a reverse proxy with TLS and real auth, or keep it
on a network you already trust. [`docs/deployment.md`](docs/deployment.md) has
the full story, including the things that bite (volume ownership, cellpy's own
directories, and why server-side figure export is not in the image).

### Developer mode

```bash
run --dev                 # or set CSG_DEV_MODE=1
```

The regular UI shows a curated set of plot types, because cellpy registers far
more than are useful on any one dataset. Developer mode adds **every summary
family cellpy registers**, grouped by whether the loaded cells can actually plot
them — the rest are listed but disabled, showing which summary columns are
missing rather than rendering a blank chart. Availability comes from cellpy
itself (`family.summary_options()`), so families whose columns the *collector*
builds — CV splits, the full-cell standards — are offered rather than hidden. It
also raises the glob/batch file cap (10 → 500) for stress-testing. A **DEV**
badge marks the session.

Two extra Cell-explorer views appear alongside the regular ones:

| View | What it shows |
|---|---|
| **Raw traces** | The raw time series (voltage/current/capacity, or all of it) |
| **Raw + step/cycle info** | Raw traces annotated with step and cycle boundaries |

Raw data is big — 155k rows for a single demo cell — so **Max points** caps what
is drawn (cellpy does the downsampling itself since 2.1.2,
[#867](https://github.com/jepegit/cellpy/issues/867)) and the chart says when it
applied: *"showing 1,882 of 155,754 points"*. For raw *data* rather than a
picture, use **Export cells → csv**.

Clicking the **DEV** badge opens **Diagnostics**:

- **Logs** — a live tail of the last 2000 records, filterable by level. cellpy's
  own records arrive here too (they go through stdlib logging), so a failed load
  can be diagnosed without opening `cellpy_debug.log`.
- **Jobs** — every job this session with how long it waited for a worker and how
  long it ran.

**copy** puts either view on the clipboard as plain text, ready for a bug report.

Off by default and not reachable from the UI: regular users get the curated set.

> First run downloads a few small example cells from the cellpy example-data
> repository (then cached). Everything else is fully offline.

## Projects on disk

A project is a portable folder — move it, zip it, share it:

```
<project>/
├── project.json      # manifest: name, timestamps, versions, per-cell grouping/labels/selection
├── cellpy.toml       # optional: cellpy settings pinned to this project
└── data/
    ├── c1.cellpy     # every loaded cell saved as a self-contained cellpy file
    └── c2.cellpy
```

Projects live under `~/.cellpy_simple_gui/projects/` by default; **Save** writes the
current set there and **Open** restores it (physical quantities come from the
`.cellpy` files, organisational metadata from the manifest). Changes in the UI
are **not** written until you Save.

That split is also why Save is quick. Re-saving reuses a `.cellpy` file whenever
the cell it holds provably has not changed — nothing edited it since it was
read, the file is still there, and its size and timestamp still match. Renaming
or regrouping touches only `project.json`. Editing mass, area, nominal capacity
or cycle mode rewrites the summary, so those cells are written out again; if
anything is uncertain, the file is rewritten. Save-As always produces a complete
project.

### Moving the data directory

`CSG_DATA_DIR` decides where projects live. It defaults to
`~/.cellpy_simple_gui`, so desktop installs need nothing; a container points it
at a volume:

```bash
CSG_DATA_DIR=/data cellpy-simple-gui --server --no-open
```

`~` and relative paths are expanded and resolved, and reading the setting never
creates anything — whoever writes creates what it needs.

**cellpy keeps its own directories**, separate from the app's, and they default
under `$HOME` too. Anything running where `$HOME` is not persistent should place
them deliberately. Every field under `[paths]` has an environment override,
`CELLPY_PATHS__<FIELD>`:

```bash
CELLPY_PATHS__CELLPYDATADIR=/data/cellpy/cellpyfiles
CELLPY_PATHS__OUTDATADIR=/data/cellpy/out
CELLPY_PATHS__EXAMPLESDIR=/data/cellpy/examples   # bundled demo cells cache here
CELLPY_PATHS__FILELOGDIR=/data/cellpy/logs
```

Overrides apply per field, so setting one leaves the rest alone. The **cellpy
version badge** in the app shows every setting and which layer it came from,
which is the quickest way to confirm a deployment is reading what you think.

### Local vs served: what the app may touch

Typing `D:\data\*.res` into the app is the point of a desktop tool. Answering
requests over a network with that same freedom is arbitrary read and write on
the **host**, behind nothing but a per-launch token — so the app has two modes,
chosen by the bind address:

| | bound to | paths |
|---|---|---|
| **local** | loopback | anywhere you can reach, as always |
| **served** | anything else | inside `CSG_DATA_DIR` only |

In served mode, absolute paths, `..`, drive letters, UNC paths and symlinks that
point outward are all refused — the *resolved* location is what gets checked, so
a link that looks innocent does not get through. Globs are rooted at the data
directory and their results filtered, because `**` through a symlinked directory
is the classic way out.

One case the bind address gets wrong: an instance on loopback **published by a
reverse proxy** looks local from inside, because the proxy connects from
loopback. Force it:

```bash
CSG_ALLOW_HOST_PATHS=0
```

`/api/system/capabilities` reports `host_paths_allowed` and `sandbox_root`, so
you can confirm which rules an instance is running under.

> A served instance currently reads files that are already inside its data
> directory — mount your data there. Browser upload is
> [#133](https://github.com/cellpy/cellpy-simple-gui/issues/133).

### Remote files (SSH / SFTP)

On a **desktop** (local) instance you can paste a single remote file URI into
the path field of **Add cells… → cellpy files** or **Raw instrument files**
(it is staged as *remote*; there is no existence check until the load runs):

```text
sftp://user@hostname/home/user/lab/raw/20160805_test001_45_cc_01.res
```

Supported schemes: `ssh://`, `sftp://`, `scp://` (cellpy’s `OtherPath`). cellpy
copies the file to a local temp dir, then opens it. Served instances refuse
remote URIs — use upload or a volume mount instead.

Credentials come from cellpy’s own env / `~/.env_cellpy` (the app already loads
them with cellpy’s config stack):

| Variable | Purpose |
| --- | --- |
| `CELLPY_KEY_FILENAME` | Path to an SSH private key (preferred) |
| `CELLPY_PASSWORD` | Password fallback |

Use a **fully resolvable hostname** (or IP) in the URI — OpenSSH `Host` aliases
often fail with Paramiko (cellpy #687). See cellpy’s
[remote paths](https://cellpy.readthedocs.io/) docs for more.

#### Find files in a remote folder

Globs (`*.res`) do not work on remote URIs. Instead, open **Find in a remote
folder…** inside the *Add cells…* dialog (cellpy files or raw tab), paste a
remote **directory** URI (`sftp://user@hostname/home/user/lab/raw/my_project/`)
and, optionally, a name filter (`cc_01` means *contains*; `2016*_cc_*` is used
as a glob). The app walks the folder recursively with cellpy’s `filefinder` —
on the raw tab it looks for the selected instrument’s extension(s), on the
cellpy tab for `.cellpy` / `.h5` — and the matches appear in the **staged
list**, capped at “max”, where you can remove any of them before pressing
**Load N files** / **Import N files**. Prefer a project-scoped folder: listing
a whole shared raw-data tree over SFTP is slow. Served instances refuse remote
find like any other remote URI.

### Per-project cellpy settings

Drop a `cellpy.toml` next to `project.json` and the app activates it as cellpy's
**project** configuration layer whenever that project is open — so the project
pins the settings its data was analysed with (cycle mode, units, defaults)
instead of being silently re-interpreted under whatever your global config says
today:

```toml
[reader]
cycle_mode = "cathode"

[units]
mass = "g"
```

A **cellpy.toml** chip appears next to the project name while those settings are
active; closing the project restores your own configuration. Settings never leak
from one project to the next. Click the chip (or the cellpy version badge) to see
every setting and which layer it came from — defaults, your user `cellpy.toml`,
the project file, or the environment.

Rather than writing the file by hand, open that panel with a project loaded and
hit **Pin settings to project**: it captures the `reader`, `units` and
`defaults` currently in effect. Only those sections are written — `paths` would
bake your machine's directory layout into a folder meant to be portable, and
leaving out `instruments`/`db` means the file structurally cannot contain a
credential.

The app never writes your user `cellpy.toml`; that file belongs to you and is
shared with your notebooks and the `cellpy` CLI.

## How it is built

Three layers, each testable on its own. The UI never imports cellpy; the core
never imports the web framework.

```
┌──────────────────────────────────────────────────────────────┐
│  desktop shell (pywebview window)                             │
│    └── web/  Alpine.js + Plotly.js  (served by the backend)   │
├──────────────────────────────────────────────────────────────┤
│  api/   FastAPI (127.0.0.1 + per-launch token)                │
│         routers · JobManager (threads + SSE progress)         │
├──────────────────────────────────────────────────────────────┤
│  core/  pure Python — no web imports                          │
│         models · library · plotting · export                  │
│         cellpy_adapter.py  ← the ONLY module that imports cellpy
└──────────────────────────────────────────────────────────────┘
                              │
                          cellpy ≥ 2.1
```

```
src/cellpy_simple_gui/
├── core/
│   ├── cellpy_adapter.py   # every cellpy call lives here (get, summary, get_cap, example_data)
│   ├── models.py           # Pydantic domain models (CellMeta, SummaryPlotSpec, …)
│   ├── library.py          # in-memory library of loaded cells = source of truth
│   ├── collect.py          # bridges the library into cellpy.collect / .plotting (from_cells)
│   ├── plotting.py         # thin: delegates figures to cellpy via collect.py
│   ├── projects.py         # save/open portable project folders (manifest + .cellpy files)
│   ├── files.py            # glob/path expansion with a max cap + messages
│   ├── paths.py            # the sandbox: what a served instance may read and write
│   ├── uploads.py          # files brought in through the browser
│   └── export.py           # csv / xlsx / parquet / json from cellpy collections
├── api/
│   ├── app.py              # FastAPI factory + index route
│   ├── jobs.py             # tiny thread-pool JobManager (progress + cancel)
│   └── routers/            # cells · plots · export · jobs · projects · ingest · system
├── web/                    # templates/ (Jinja) + static/ (css, js, vendored Plotly & Alpine)
├── logging_setup.py        # loguru terminal logging + stdlib bridge
├── server.py               # uvicorn-in-a-thread helper
├── desktop.py              # pywebview launcher
└── __main__.py             # entry point (desktop / --server)
```

**Powered by cellpy, not around it.** Summaries, grouping (incl. group averaging
with spread), per-cell cycle curves, the plots and the multi-format export are all
produced by cellpy's own `collect` / `plotting` subsystems. On **cellpy ≥ 2.1.1**
the app feeds them a real `Batch` via `cellpy.collect.from_cells(...)` built from
the in-memory library (`collect.py`), and discovers instruments via
`cellpy.list_instruments()`. The cellpy surface stays isolated to
`cellpy_adapter.py` + `collect.py`.


## Building your own cellpy app

This app is a reference implementation. It is a poor thing to start *from* — at
~8,000 lines, most of what you would read is machinery you have not decided you
want yet.

[`examples/starter/`](examples/starter/) is the starting point instead: one file,
~340 lines, that loads cells, builds a cellpy `Collection`, plots it and exports
the numbers, and nothing else.

```bash
uv run examples/starter/app.py     # then open http://127.0.0.1:8000
```

It carries its own dependencies in a PEP 723 header, so `uv run --script app.py`
also works once you have copied it somewhere else — which is what it is for. See
[`examples/starter/README.md`](examples/starter/README.md) for the four cellpy
calls it is built from, how to add a plot, and where in this repository to look
when you outgrow it.

Then [`docs/guides/`](docs/guides/) — seven guides organised by what you are
trying to do rather than by module:

| | Guide | You are trying to |
|---|---|---|
| 1 | [Getting cells into memory](docs/guides/01-loading-cells.md) | open a file, and find out why one will not open |
| 2 | [Cells into a Collection](docs/guides/02-collections.md) | get many cells into one frame you can plot and export |
| 3 | [Plotting a collection](docs/guides/03-plotting.md) | draw it, and build a plot menu that is not a hard-coded list |
| 4 | [Exporting data and figures](docs/guides/04-exporting.md) | get numbers and images out, in memory |
| 5 | [Configuration](docs/guides/05-configuration.md) | find out where cellpy is reading from, and change it safely |
| 6 | [Process state and threading](docs/guides/06-state-and-threading.md) | call cellpy from a worker thread without cross-talk |
| 7 | [What cellpy will and will not do for you](docs/guides/07-delegation.md) | decide whether to write the code or let cellpy do it |

Every Python block in those guides is executed by
[`tests/test_guides.py`](tests/test_guides.py), so a guide whose code stops
working against a new cellpy breaks CI rather than the reader.

### If you are a coding agent

Same knowledge, different shape:

| | |
|---|---|
| [`llms.txt`](llms.txt) | index of everything here, per the [llms.txt](https://llmstxt.org) convention |
| [`llms-full.txt`](llms-full.txt) | the guides, the API reference and the starter app in one file (~120 KB) |
| [`docs/api-reference.md`](docs/api-reference.md) | the 44 cellpy calls that matter, with signatures **introspected from the installed cellpy** and one line each of what a signature cannot tell you |
| [`skills/cellpy-app/`](skills/cellpy-app/) | a Claude Skill packaging the guides — copy it into `.claude/skills/` |

All four are generated ([`tools/`](tools/)) and checked in CI, so they cannot
drift from the guides or from the installed cellpy. Regenerate after a cellpy
upgrade:

```bash
uv run tools/gen_api_reference.py && uv run tools/gen_llms_txt.py
```


## Development

```bash
uv sync --extra dev
uv run pytest            # core unit tests + FastAPI integration tests
uv run pytest -m essential   # critical-path subset (also run in CI)
```

The suite passes without the `desktop` extra — that is the point of it being an
extra, and one test skips accordingly. Add `--extra desktop` to run that one too.

Optional Playwright GUI smoke tests (server/browser mode, not pywebview):

```bash
uv sync --extra dev --extra e2e
uv run playwright install chromium
uv run pytest -m e2e
```

Without the `e2e` extra / Chromium browsers, those tests skip so a plain
`uv run pytest` stays green.

GitHub Actions runs the `essential` marker. One workflow decides internally
whether the change touched code, so every PR gets exactly one `essential` check
run — and a green one means either "tests passed" or "no code changed", never
"a second workflow reported first". If the base commit cannot be resolved, it
runs the tests rather than guessing.

Optional static figure export (**Export ▾ → Figure → PNG / SVG / PDF**, via kaleido):

```bash
uv sync --extra export
```

Without that extra, data export still works; figure formats show a toast pointing
at the install command above.

### Why we develop this app

The app is meant to inspire other users of cellpy to make their own apps. We
also use the development of this app to find pain-points in the cellpy library.

Already now, building this surfaced a number of cellpy rough edges, filed upstream as
[jepegit/cellpy#785–#791](https://github.com/jepegit/cellpy/issues/785) — **most were fixed in cellpy 2.1.1**. The full write-up and per-item status is in
[CELLPY_PAINPOINTS.md](CELLPY_PAINPOINTS.md).

## Status

This is an MVP / reference implementation

Done: load `.cellpy`/`.h5` files, **raw-file ingestion** (Arbin `.res` / Maccor / Neware /
PEC → cellpy), cycle-summary & cell-explorer plotting, editable cell list + Manage cells
modal, CSV/Excel/Parquet/JSON export with feedback, **save/open/close portable projects**,
batch-journal loading with error toasts, loguru console logging, and essential-test CI.

> Arbin `.res` loads on Windows through the Access ODBC driver (no separate mdbtools needed
> in this environment). Instruments needing extra engines will surface a clear error.

### Future plans

- packaging into a Windows installer (PyInstaller + InnoSetup, bundling WebView2).
- show-case more of the plots

## License

See [LICENSE](LICENSE).

==============================================================================
FILE: docs/guides/README.md
==============================================================================

# Building on cellpy

Seven guides, organised by what you are trying to do rather than by module. The
module layout is discoverable from the source; the task mapping is not.

They are written against **cellpy 2.1.3** from building
[cellpy-simple-gui](../../README.md) on it — 29 issues filed upstream, 25 closed.
Where a guide names a trap, it is one we walked into, and the evidence is the
measurement rather than an opinion.

| | Guide | You are trying to |
|---|---|---|
| 1 | [Getting cells into memory](01-loading-cells.md) | open a file, a folder, or a journal, and find out why one will not open |
| 2 | [Cells into a Collection](02-collections.md) | get many cells into one frame you can plot and export |
| 3 | [Plotting a collection](03-plotting.md) | draw it, and build a plot menu that is not a hard-coded list |
| 4 | [Exporting data and figures](04-exporting.md) | get numbers and images out, in memory, without temp files |
| 5 | [Configuration](05-configuration.md) | find out where cellpy is reading from, and change it safely |
| 6 | [Process state and threading](06-state-and-threading.md) | call cellpy from a worker thread without cross-talk |
| 7 | [What cellpy will and will not do for you](07-delegation.md) | decide whether to write the code or let cellpy do it |

New to this? [`examples/starter/`](../../examples/starter/) is a running app in
one file that does 1 → 4. Read it first; these guides are what is behind it.

Looking for a signature rather than an explanation?
[`docs/api-reference.md`](../api-reference.md) lists the 44 calls that matter,
generated from the installed cellpy. Agents: [`llms.txt`](../../llms.txt).

## Every Python block here runs

Blocks fenced as ` ```python ` are executed, in document order, by
[`tests/test_guides.py`](../../tests/test_guides.py). A guide whose code stops
working against a new cellpy breaks CI rather than the reader.

Blocks fenced as ` ```pycon ` are REPL transcripts — usually illustrating
something going *wrong*, which is why they are not run.

That means you can copy a guide from the top and it will work, and it means the
outputs quoted here were real when written. It also means the guides are
deliberately narrow: everything shown had to be demonstrable on the bundled
example cells.

## Two things worth knowing before you start

**Hand cellpy your cells, do not hand yourself cellpy's job.**
`cellpy.collect.from_cells` turns cells you already hold into a real `Batch`, and
from there grouping, group averaging, spread bands, per-cell cycle isolation and
multi-format export are cellpy's problem. The single most expensive mistake
available here is assembling frames by hand and slowly reimplementing a worse
copy of `cellpy.collect`.

**The frames are [polars](https://pola.rs), the cells are pandas.**
`collection.data` is a polars DataFrame; `cell.data.summary` and `cell.data.raw`
are pandas. Crossing that boundary is explicit (`.to_pandas()`), and forgetting
which side you are on is the most common five-minute confusion in this codebase.

## If something here is wrong

These guides describe a moving library. [`CELLPY_PAINPOINTS.md`](../../CELLPY_PAINPOINTS.md)
records every rough edge found while building on it and which upstream issue
closed it, so if a workaround here looks unnecessary, check there first — it may
already have been fixed and not yet deleted.

==============================================================================
FILE: docs/guides/01-loading-cells.md
==============================================================================

# 1. Getting cells into memory

*You have files. You want `CellpyCell` objects.*

Everything on this page goes through one function. That is the good news, and it
is genuinely one of the nicest things about the library: there is no loader class
to pick, no reader to construct, no session to open.

```python
import warnings

import cellpy
from cellpy.utils import example_data

warnings.simplefilter("ignore")  # the demo files are old enough to grumble

cell = example_data.cellpy_file()
print(type(cell).__name__, cell.cell_name, len(cell.get_cycle_numbers()), "cycles")
```

```text
CellpyCell 20180418_sf033_4_cc 304 cycles
```

`example_data` downloads once into `~/cellpy_data/examples` and caches, which
makes a zero-setup demo trivial — worth wiring into any app you build, because
"press this to see it work" beats a file dialog on first run.

## Opening a real file

```python
import cellpy
from cellpy.utils import example_data

path = example_data.neware_file_path()   # a real Neware export — note: .csv

# Only the filename is required; the rest is metadata cellpy cannot get
# from the file.
cell = cellpy.get(
    filename=path,
    instrument="neware_txt",
    mass="0.47 mg",              # unit strings are accepted, and are clearer
    area="1.77 cm**2",
    nominal_capacity="372 mAh/g",
)
print(path.name, "·", len(cell.get_cycle_numbers()), "cycles ·", round(cell.mass, 3), "mg")
```

```text
neware_uio.csv · 4 cycles · 0.47 mg
```

Two things about that call are worth internalising.

**Units can be strings.** `mass="0.47 mg"` beats `mass=0.47` plus a comment, and
cellpy parses it. Wherever an argument is a physical quantity, try the string.

**`mass` is not cosmetic.** Every `*_gravimetric` column in the summary is
computed against it — and if you omit it, the columns are still there:

```python
no_mass = cellpy.get(filename=path, instrument="neware_txt")
print("mass:", no_mass.mass)
print("first discharge, per gram:", round(
    no_mass.data.summary["discharge_capacity_gravimetric"].iloc[0], 1))
print("with the real mass:      ", round(
    cell.data.summary["discharge_capacity_gravimetric"].iloc[0], 1))
```

```text
mass: 1.0
first discharge, per gram: 5086.3
with the real mass:       2390.1
```

Nothing failed. The mass silently defaulted to 1.0 mg, and you get a chart with
correct-looking axes and numbers that are wrong by a factor of the real mass. If
your app takes files from users, ask for mass at ingestion time, not later.

## Which instrument?

`instrument=` is a string, and the list is discovered at runtime rather than
hard-coded, so it depends on the installed cellpy:

```python
import cellpy

instruments = cellpy.list_instruments()
print(len(instruments), "loaders")
print(instruments[0])
```

```text
13 loaders
{'id': 'arbin_res', 'label': 'Arbin (res)', 'models': ['default'], 'suffixes': ['.res']}
```

`suffixes` is what you want for a file-dialog filter; `models` is a
sub-selection some instruments offer (the literal `"default"` entry means "no
model chosen", so drop it before showing the list to anyone).

You can also let cellpy pick from the suffix by leaving `instrument` out. Do that
for convenience, not for correctness — the Neware file above is a `.csv`, and so
are the PEC and custom-format examples.

> **`.h5` is special.** A set `instrument=` wins over the `.h5` / `.hdf5` suffix,
> so you can load an HDF5 file that is *not* a cellpy file by naming its loader.
> That was not always true ([cellpy#819](https://github.com/jepegit/cellpy/issues/819),
> fixed in 2.1.2a2) — before it, `auto_pick_cellpy_format=False` was required, and
> code that still passes it can drop it.

## Discovered is not the same as usable

This is the trap on this page, and it cost a full build cycle to notice.

`list_instruments()` reports what cellpy *registers*, not what this machine can
actually read. Arbin `.res` is a Microsoft Access database, so it needs a reader
that is nobody's default install: `mdbtools` (providing the `mdb-export` binary)
on Linux and macOS, or the Access Database Engine on Windows. Without it, loading
does not raise something helpful:

```pycon
>>> cellpy.get(filename="cell.res", instrument="arbin_res")
FileNotFoundError: [Errno 2] No such file or directory: 'mdb-export'
```

Worse, in a batch import the failure can be a per-file error inside a result
rather than an exception. Our smoke test asserted the import job reached
`status: "done"`. It did — and imported zero cells, for a whole build cycle,
because the failure lived in the payload:

```json
{"added": [], "errors": ["Arbin demo (.res): [Errno 2] No such file or directory: 'mdb-export'"]}
```

Two things follow. **Probe before you offer**, so the UI can grey an entry out
rather than let someone pick a file and wait to find out:

```python
import shutil
import sys


def arbin_res_readable() -> bool:
    """Can this machine read an Arbin .res? Never raises — no opinion is 'yes'."""
    try:
        if sys.platform != "win32":
            return shutil.which("mdb-export") is not None
        import pyodbc

        return any("microsoft access driver" in d.lower() for d in pyodbc.drivers())
    except Exception:  # noqa: BLE001 - an inconclusive probe must not hide a working loader
        return True


print("arbin .res readable here:", arbin_res_readable())
```

And **translate the error**, because "mdb-export" tells a battery researcher
nothing:

```python
_ENVIRONMENT_ERRORS = (
    (("im002", "odbc driver manager", "no default driver"),
     "Reading Arbin .res needs the Microsoft Access Database Engine (64-bit), "
     "a free Microsoft download."),
    (("mdb-export",),
     "Reading Arbin .res on Linux/macOS needs mdbtools, which provides "
     "`mdb-export`. Debian/Ubuntu: apt install mdbtools"),
)


def explain(exc: BaseException) -> str:
    """Anything unrecognised passes through: a wrong explanation is worse."""
    text = str(exc).lower()
    for signatures, explanation in _ENVIRONMENT_ERRORS:
        if any(s in text for s in signatures):
            return explanation
    return str(exc)


print(explain(FileNotFoundError("[Errno 2] ... 'mdb-export'")))
```

A related quiet failure: `arbin_sql` and `arbin_sql_7` raise `ImportError` during
discovery when `libodbc.so.2` is absent, so the list comes back with 11 entries
instead of 13 and nothing says why. If your instrument count matters, assert on
it. *(Both raised as [cellpy#938](https://github.com/jepegit/cellpy/issues/938).)*

## Looking before you load

Loading a large `.res` takes real time, so for a file picker or an import preview
you usually want the metadata without the data:

```python
import cellpy
from cellpy.utils import example_data

meta = cellpy.read_meta(example_data.cellpy_file_path())
print(sorted(meta)[:6])
print("mass:", meta["cell"]["mass"])
```

```text
['active_test_id', 'cell', 'cellpy_file_version', 'cellpy_units', 'cycle_schema_version', 'frames_had_test_id']
mass: 0.28898447708417657
```

**`read_meta` reads cellpy files only.** It goes straight to the HDF5 reader
whatever the suffix, so handing it a raw `.res` produces an HDF5 superblock
traceback rather than "that is not a cellpy file". Guard it on your side:

```python
from pathlib import Path

CELLPY_SUFFIXES = {".cellpy", ".h5", ".hdf5"}


def peek(path) -> dict | None:
    path = Path(path)
    if path.suffix.lower() not in CELLPY_SUFFIXES:
        return None          # raw file: there is no cheap read, load it properly
    return cellpy.read_meta(path)


print(peek(example_data.arbin_file_path()))
```

## Asking the user for the right metadata

Different instruments need different things, and hard-coding a form per
instrument ages badly. cellpy will describe the fields for you:

```python
import cellpy

schema = cellpy.instrument_meta_schema("arbin_res")
for field in schema["fields"][:3]:
    print(f"{field['name']:<8} required={field['required']!s:<5} unit={field['unit']}")
```

```text
mass     required=True  unit=mg
area     required=False unit=cm**2
loading  required=False unit=mg/cm**2
```

Each field carries `name`, `required`, `type`, `unit`, `default`, `maps_to` and
`help` — enough to generate an ingestion form rather than maintain one.

## Many files at once

There is no batch-load call: loop, and decide your own failure policy. The policy
matters more than the loop.

```python
def load_many(paths, **kwargs):
    """One bad file should not lose the other nine."""
    cells, errors = {}, []
    for path in paths:
        try:
            cells[Path(path).stem] = cellpy.get(filename=path, **kwargs)
        except Exception as exc:  # noqa: BLE001 - report it, keep going
            errors.append(f"{Path(path).name}: {explain(exc)}")
    return cells, errors


cells, errors = load_many(
    [example_data.cellpy_file_path(), "/definitely/not/here.res"]
)
print(len(cells), "loaded ·", errors)
```

If you expand globs, **cap the expansion**. A stray `*` in a directory of
archives will otherwise pull in a few hundred files and look like a hang; a low
default with a clear "showing the first N" message is kinder than an honest but
unbounded loop.

## Where to go next

Cells in a dict, keyed by a name you chose, is exactly the shape
[guide 2](02-collections.md) starts from.

---

*Sources: `cellpy.get`, `cellpy.list_instruments`, `cellpy.read_meta`,
`cellpy.instrument_meta_schema`, `cellpy.utils.example_data`. Traps from
[CELLPY_PAINPOINTS.md](../../CELLPY_PAINPOINTS.md) §31 and
[cellpy-simple-gui#143](https://github.com/cellpy/cellpy-simple-gui/issues/143).*

==============================================================================
FILE: docs/guides/02-collections.md
==============================================================================

# 2. Cells into a Collection

*You have `CellpyCell` objects. You want one frame you can plot and export.*

The bridge is `from_cells`, and it is the most important call in this whole set
of guides:

```python
import warnings

import cellpy
from cellpy.collect import collect_summaries, from_cells
from cellpy.utils import example_data

warnings.simplefilter("ignore")

cells = {
    "sf033": example_data.cellpy_file(),
    "rate": cellpy.get(filename=example_data.rate_file()),
}
batch = from_cells(cells)
print(type(batch).__name__)
```

```text
Batch
```

That is a **real** cellpy `Batch`, built from cells already in memory, with no
journal written to disk. It matters because everything downstream — grouping,
group averaging, spread bands, per-cell cycle isolation, multi-format export —
is written against `Batch`, so you get all of it by handing over a dict.

The alternative, assembling summary frames yourself, is the single most expensive
mistake available in this library. It looks like less work for about a day.

> Before `from_cells` ([cellpy#787](https://github.com/jepegit/cellpy/issues/787),
> 2.1.1) this app maintained a hand-rolled batch shim. Deleting it was the
> largest single simplification in the project.

## Collecting

Four collectors, one shape. Each takes the batch and an options object, and each
returns a `Collection`:

| Call | Gives you |
|---|---|
| `collect_summaries(batch, columns=…)` | per-cycle summary values — capacity, CE, IR, voltages |
| `collect_cycles(batch, options=CurveOptions(…))` | voltage/capacity curves for chosen cycles |
| `collect_ica(batch, options=IcaOptions(…))` | dQ/dV |
| `collect_dva(batch, options=IcaOptions(…))` | dV/dQ |

```python
summary = collect_summaries(
    batch,
    columns=("charge_capacity_gravimetric", "discharge_capacity_gravimetric"),
)
print(summary.data.height, "rows")
print(summary.data.columns)
```

```text
325 rows
['cell', 'group', 'sub_group', 'group_label', 'label', 'cycle_num',
 'charge_capacity_gravimetric', 'discharge_capacity_gravimetric']
```

325 = 304 cycles from one cell plus 21 from the other. The frame is **long over
cells** and wide over the columns you asked for, and it carries the identity
columns (`cell`, `group`, `label`) that the plotters use to split traces.

```python
from cellpy.collect import collect_cycles
from cellpy.collect.options import CurveOptions

curves = collect_cycles(batch, options=CurveOptions(cycles=(1, 5, 10)))
print(curves.data.columns, "·", curves.data.height, "points")
```

```text
['potential', 'capacity', 'cell', 'group', 'sub_group', 'cycle_num'] · 6916 points
```

`CurveOptions` also carries `mode` (charge / discharge) and `method`, so you do
not have to slice the frame afterwards. `IcaOptions` carries `voltage_resolution`
and `capacity_resolution` instead.

**The differential collectors use a different column vocabulary.** Worth printing
once rather than assuming:

```python
from cellpy.collect import collect_ica
from cellpy.collect.options import IcaOptions

ica = collect_ica(batch, options=IcaOptions(cycles=(1, 5)))
print(ica.data.columns)
```

```text
['cycle', 'direction', 'voltage', 'capacity', 'dqdv', 'cell', 'group', 'sub_group']
```

Note **`cycle`, not `cycle_num`** — `collect_summaries` and `collect_cycles` use
`cycle_num`, and `collect_ica` / `collect_dva` do not. Code that filters an ICA
frame by `cycle_num` after reading the section above will not find the column.
The extra `direction` column carries both half-cycles.

> **`collect_*` fixes a bug you would otherwise write.** The collectors isolate
> cycles per cell, so asking for "cycles 1, 5, 10" across cells with different
> cycle counts does not narrow every cell to the intersection. That is exactly
> the kind of correctness an app cannot cheaply get right on its own.

## What a Collection is

A small object, and that is the point:

```python
print([n for n in dir(summary) if not n.startswith("_")])
```

```text
['data', 'is_grouped', 'kind', 'meta', 'name', 'plot', 'save', 'to_image', 'to_wide']
```

- **`.data`** — a [polars](https://pola.rs) DataFrame. This is the numbers.
- **`.plot()`** — returns a plotly `Figure`. See [guide 3](03-plotting.md).
- **`.save()` / `.to_image()`** — files. See [guide 4](04-exporting.md).
- **`.is_grouped`** — whether these are averaged series or per-cell ones.
- **`.to_wide()`** — one column per cell, for people who want a spreadsheet:

```python
wide = summary.to_wide(values="discharge_capacity_gravimetric")
print(wide.shape, wide.columns)
```

```text
(304, 3) ['cycle_num', 'sf033', 'rate']
```

## Grouping and averaging

Groups are assigned when you build the batch, not when you collect:

```python
batch = from_cells(
    cells,
    groups={"sf033": 1, "rate": 1},
    group_labels={1: "silicon"},
)
```

Then `group_it=True` averages within each group:

```python
grouped = collect_summaries(
    batch, columns=("discharge_capacity_gravimetric",), group_it=True
)
print("is_grouped:", grouped.is_grouped)
print(grouped.data.columns)
```

```text
is_grouped: True
['group', 'cycle_num', 'variable', 'mean', 'std', 'group_label']
```

**Read those columns again.** Grouping does not merely change the values, it
changes the *schema*: `cell` is gone, the column you asked for has moved into a
`variable` column, and the value is now `mean` with a companion `std`. Any code
downstream that indexes `collection.data` by column name has two cases to handle,
which is what `is_grouped` is for.

`std` is also what makes a spread band possible — `collection.plot(spread=True)`
draws mean ± 1σ, and only means anything when `is_grouped` is true. Check first:

```python
def draw(collection, spread=False):
    return collection.plot(spread=spread and collection.is_grouped)


print(type(draw(grouped, spread=True)).__name__)
```

```text
Figure
```

> Singleton groups used to make this all-or-nothing: one group of one cell and
> `group_it=True` returned nothing averaged at all
> ([cellpy#816](https://github.com/jepegit/cellpy/issues/816), fixed in 2.1.2a2).
> Since then one collection averages multi-member groups *and* keeps singletons as
> per-cell rows, so the app-side partition-and-merge that used to be needed here
> is gone. If you find code doing that, delete it.

## polars here, pandas there

The boundary is not arbitrary, but it is easy to trip on:

| | Type |
|---|---|
| `collection.data` | polars |
| `collection.to_wide(...)` | polars |
| `cell.data.summary`, `cell.data.raw`, `cell.data.steps` | pandas |
| `cell.get_cap(...)` | pandas |

```python
print(type(summary.data).__module__, "vs", type(cells["sf033"].data.summary).__module__)
```

```text
polars.dataframe.frame vs pandas
```

Crossing it is explicit and cheap — `collection.data.to_pandas()` — but do it at
the edge of your code rather than in the middle of it. The failure mode is a
`.iloc` on a polars frame twenty minutes into a debugging session.

## Two things that go wrong

**A value that is not a cell raises.** `from_cells` refuses the mapping and
names the bad keys ([cellpy#939](https://github.com/jepegit/cellpy/issues/939)).
Older cellpy dropped that value with no exception, which showed up later as a
plot with one fewer line than you have cells:

```python
try:
    from_cells({"good": cells["sf033"], "oops": example_data.rate_file()})
except ValueError as exc:
    print(type(exc).__name__)
```

```text
ValueError
```

`example_data.rate_file()` returns a *path*, not a cell — an easy mistake, since
its sibling `example_data.cellpy_file()` returns a cell. A plain `42` raises the
same way. If you build the mapping from anything dynamic, check it before the
call:

```python
from cellpy.readers.cellreader import CellpyCell   # note: not cellpy.cellreader

def checked(mapping: dict) -> dict:
    bad = [k for k, v in mapping.items() if not isinstance(v, CellpyCell)]
    if bad:
        raise TypeError(f"not cells: {bad}")
    return mapping


print(len(checked({"sf033": cells["sf033"]})), "cell(s)")
```

**A column your cells do not have collects to nothing**, rather than raising —
you get an empty chart with correct-looking axes. Check before you collect:

```python
def missing(cells: dict, columns) -> list[str]:
    """Union across cells: one odd cell should not veto the rest."""
    have = set()
    for cell in cells.values():
        have |= set(cell.data.summary.columns)
    return [c for c in columns if c not in have]


print(missing(cells, ("discharge_capacity_gravimetric", "no_such_column")))
```

```text
['no_such_column']
```

Union rather than intersection is a deliberate choice: a collected plot spans the
whole selection, so a single cell lacking a column should not make the plot
unavailable for the others. Whether that is right depends on your app — but it
should be a decision, not an accident.

## Where to go next

You have a `Collection`. [Guide 3](03-plotting.md) draws it — including how to
build a plot menu from cellpy's own registry instead of a hard-coded list.

---

*Sources: `cellpy.collect.from_cells`, `collect_summaries`, `collect_cycles`,
`collect_ica`, `collect_dva`, `cellpy.collect.options`, `Collection`.*

==============================================================================
FILE: docs/guides/03-plotting.md
==============================================================================

# 3. Plotting a collection

*You have a `Collection`. You want a figure — and a plot menu you do not have to
maintain by hand.*

The easy half first:

```python
import warnings

from cellpy.collect import collect_summaries, from_cells
from cellpy.utils import example_data

warnings.simplefilter("ignore")

cell = example_data.cellpy_file()
batch = from_cells({"sf033": cell})
collection = collect_summaries(
    batch, columns=("charge_capacity_gravimetric", "discharge_capacity_gravimetric")
)

figure = collection.plot()
print(type(figure).__module__ + "." + type(figure).__name__, "·", len(figure.data), "traces")
```

```text
plotly.graph_objs._figure.Figure · 2 traces
```

It is a real plotly figure. Everything you know about plotly applies — restyle
it, add traces, serialise it with `plotly.io.to_json` and hand it to the browser.
cellpy does not wrap it.

## Making it look like your app

`Collection.plot` takes the hooks you would otherwise reach in and set yourself
([cellpy#801](https://github.com/jepegit/cellpy/issues/801)):

```python
figure = collection.plot(
    layout_updates={
        "paper_bgcolor": "white",
        "plot_bgcolor": "white",
        "font": {"family": "Inter, system-ui, sans-serif", "size": 12},
    },
    height_per_panel=250,
    figure_border_height=120,   # room for axis labels; without it the last facet clips
)
print(figure.layout.paper_bgcolor)
```

Prefer these over post-processing the figure: they are applied while the figure
is built, so they survive facet layout instead of fighting it. Keep your own pass
for the things cellpy does not own — legend truncation for long cell names,
a discrete colorway, grid styling.

## The plot menu you should not hard-code

There are 25 registered plot families, and the registry describes them well
enough that a menu is a projection rather than a list you maintain:

```python
from cellpy.plotting import registry

print(len(registry.families()), "families total")
print(len(registry.families(entry_point="summary_plot")), "belong in a summary menu")
for name, description in registry.families(entry_point="summary_plot")[:3]:
    print(f"  {name:<24} {description}")
```

```text
25 families total
20 belong in a summary menu
  voltages                 End-of-charge and end-of-discharge voltages vs cycle
  capacities               Raw charge/discharge capacity vs cycle
  capacities_gravimetric   Gravimetric charge/discharge capacity vs cycle
```

**`entry_point` is the filter that keeps a menu honest.** The other five entry
points — `cycles_plot`, `raw_plot`, `ica_plot`, `dva_plot`, `cycle_info_plot` —
have one family each and their own call sites. Listing all 25 in a summary menu
puts entries there that can never work; we did exactly that until 2.1.2 offered
this filter.

## `summary_options` — the accessor that matters

Given a family name, this is how you collect it:

```python
from cellpy.plotting import registry

hdr = cell.schema.summary                     # the summary column vocabulary
family = registry.get("capacities_gravimetric")

options = family.summary_options(hdr)
print(type(options).__name__, "·", options.columns)

collection = collect_summaries(batch, options=options)
print(len(collection.plot().data), "traces")
```

```text
SummaryOptions · ('charge_capacity_gravimetric', 'discharge_capacity_gravimetric')
2 traces
```

`family.summary_options(hdr)` returns a **ready** `SummaryOptions` — the columns,
the CV-split flag, and any transforms, in the shape the collector accepts. You
layer only your own concerns on top:

```python
options = family.summary_options(hdr).replace(group_it=False, max_cycle=100)
print(collect_summaries(batch, options=options).data.height, "rows")
```

```text
100 rows
```

That one accessor took a measured investigation to find, and it is the difference
between a menu where 8 of 25 entries work and one where 15 of 20 do. Before it
existed, an app had to know out of band which families needed
`partition_by_cv=True` — knowledge that lives nowhere in the family and goes
stale silently.

## Asked-for columns are not drawn columns

This is the subtlest thing on the page, and getting it wrong tells users their
data is broken when it is not.

```python
family = registry.get("capacities_gravimetric_split_constant_voltage")

asks_for = family.summary_options(hdr).columns
draws = family.columns(hdr)
print("asks the summary for:", len(asks_for), asks_for)
print("draws:              ", len(draws))
print("manufactured by collect:", [c for c in draws if c not in asks_for])
```

```text
asks the summary for: 2 ('charge_capacity_gravimetric', 'discharge_capacity_gravimetric')
draws:               6
manufactured by collect: ['charge_capacity_gravimetric_cv', 'discharge_capacity_gravimetric_cv',
                          'charge_capacity_gravimetric_non_cv', 'discharge_capacity_gravimetric_non_cv']
```

The `*_cv` / `*_non_cv` columns are built by the collector from
`partition_by_cv=True`; the `mod_01_*` columns of the full-cell families come
from transforms. Neither is in anybody's summary.

**So judge availability on `summary_options().columns`, never on
`columns()`.** Checking the drawn names made every CV-split and full-cell family
report "your data is missing columns" when the truth was that we had never asked
for them:

```python
def is_available(family_name: str, cells: dict) -> tuple[bool, str]:
    family = registry.get(family_name)
    have = set()
    for c in cells.values():
        have |= set(c.data.summary.columns)
    needs = family.summary_options(hdr).columns
    missing = [c for c in needs if c not in have]
    return (not missing), ("missing " + ", ".join(missing) if missing else "ok")


for name in ("capacities_gravimetric_split_constant_voltage", "capacities_absolute"):
    print(name, "->", is_available(name, {"sf033": cell}))
```

```text
capacities_gravimetric_split_constant_voltage -> (True, 'ok')
capacities_absolute -> (False, 'missing charge_capacity_absolute, discharge_capacity_absolute')
```

The second answer is *correct* and now truthful: `*_absolute` columns are written
by current `make_summary()`, and this saved file predates them. "Your data lacks
these columns" is the right message — it just has to be true.

Families also declare capabilities directly, which is worth surfacing rather than
inferring:

```python
family = registry.get("capacities_gravimetric")
print(family.supports_cv_split, family.supports_formation, "·", family.mode)
```

```text
False True · gravimetric
```

`mode` is the capacity **basis** (`gravimetric` / `areal` / `absolute` / `raw` /
`None`), not the entry point. The basis is baked into the family name, so a
family and a "which basis?" control are the same choice made twice.

## Films: `kind=`, and what version you are on

A density film is a **kind**, not a layout — and which of those sentences is true
depends on your cellpy version, so check before copying anything:

```python
from importlib.metadata import version

from cellpy.plotting.collected import resolve_collected_layout_kind as resolve

installed = version("cellpy")
print("cellpy", installed)
print("kind='film'  ->", resolve(kind="film"))
```

```text
cellpy 2.1.3
kind='film'  -> ('per_cell', 'film', 'film')
```

**`kind="film"` is correct on every version**, so write that and you never have
to care. What changed is what happens when you get it wrong.

**On 2.1.2 and earlier**, `layout="film"` silently drew lines — and so did
`layout="totally_bogus"`:

```pycon
>>> resolve(layout="film")                 # cellpy 2.1.2
('film', 'line', 'fig_pr_cell')            # kind='line' — it draws lines
>>> resolve(layout="totally_bogus")        # cellpy 2.1.2
('totally_bogus', 'line', 'fig_pr_cell')   # no error, no warning
```

That is the worst kind of bug: the wrong call produced a perfectly plausible
figure, identical `scattergl` traces to `per_cell`, with nothing to suggest
anything was wrong. So it shipped.

**On 2.1.3 and later** ([cellpy#874](https://github.com/jepegit/cellpy/issues/874),
closed) `layout="film"` is accepted as an alias, and an unknown layout raises
with the fix in the message:

```pycon
>>> resolve(layout="film")                 # cellpy 2.1.3
('per_cell', 'film', 'film')
>>> resolve(layout="totally_bogus")        # cellpy 2.1.3
ValueError: Unknown layout='totally_bogus'; expected one of: per_cell, per_cycle,
summary (note: 'film' is a kind=, not a layout= — use kind='film' or layout='film')
```

If you carry a `film` → `kind="film"` translation shim for older cellpy, it is
dead code on 2.1.3 — delete it. This app did
([#154](https://github.com/cellpy/cellpy-simple-gui/issues/154)); the test that
guarded the shim survived unchanged, because it asserted the *trace type* rather
than the shim's return value.

There is also a **legacy third spelling**, `method="film"`, which still works.
Prefer `kind=`: `ica_plotter`'s own docstring advertises the legacy `method`
form, so a reader who lands there first writes the old call.

Whichever version you are on, **assert on the trace type** — "it drew something"
is not a test:

```python
from cellpy.collect import collect_cycles
from cellpy.collect.options import CurveOptions

curves = collect_cycles(batch, options=CurveOptions(cycles=(1, 5, 10)))
film = curves.plot(kind="film", layout="per_cell")
lines = curves.plot(layout="per_cell")
print("film:", {t.type for t in film.data}, "· per_cell:", {t.type for t in lines.data})
```

```text
film: {'histogram2d'} · per_cell: {'scattergl'}
```

### `direction` is a plot argument, and it defaults to charge

Films work on an ICA collection too — the axes are then voltage × cycle with
dQ/dV as the density weight. But **a dQ/dV plot shows one half-cycle by
default**, and the knob that changes it is not where you would look for it:

```python
from cellpy.collect import collect_ica
from cellpy.collect.options import IcaOptions

ica = collect_ica(batch, options=IcaOptions(cycles=(1, 5, 10)))
print("collected:", dict(ica.data.group_by("direction").len().sort("direction").iter_rows()))

def points(**kwargs):
    figure = ica.plot(**kwargs)
    return sum(len(t.x) for t in figure.data if t.x is not None)


for direction in (None, "both", "discharge"):
    kwargs = {"kind": "film", "layout": "per_cell"}
    if direction:
        kwargs["direction"] = direction
    print(f"  direction={direction!s:<9} -> {points(**kwargs)} points")
```

```text
collected: {'charge': 891, 'discharge': 1437}
  direction=None      -> 891 points
  direction=both      -> 2328 points
  direction=discharge -> 1437 points
```

`collect_ica` keeps both half-cycles; the plotters draw `charge` unless told
otherwise, and that holds for the line renderer as much as the film. So a chart
of "the dQ/dV collection" is showing 38% of the rows you collected, and nothing
on the figure says so.

The reason this is easy to miss is that **`direction` is not on `IcaOptions`** —
it is an argument to `plot()`. Reading the options dataclass to find out how to
control direction turns up nothing, which reads like "there is no control"
rather than "look one layer up". Pass `direction="both"` when you mean both.

One more knob, findable only by reading the source: `histscale` (`"abs"`,
`"abs-log"`, `"norm"`, `"hist-eq"`) sets the film's colour scaling. dQ/dV is
signed and heavy-tailed, so the unscaled default is rarely what you want.

## Spread plots lose their hover

Same collection, same columns, three renderings — and one of them has no
`hovertemplate` on any trace:

| mode | hover on the first trace |
|---|---|
| per-cell | `cellpy<br>group=1<br>variable=…<br>Cycle (n.)=%{x}<br>value=%{y}` |
| `group_it=True` | `group=1<br>variable=…<br>Cycle (n.)=%{x}<br>mean=%{y}` |
| `group_it=True, spread=True` | **`None`** — on all traces, mean lines included |

`summary_plotter` goes through plotly express, which attaches hover from the
frame; `spread_plot` builds traces with bare `go.Scatter` and never sets one.
Spread is where hover matters most — the band hides the individual cells, so the
tooltip is the only way to read a value.

Until [cellpy#875](https://github.com/jepegit/cellpy/issues/875) closes, rebuild
it after the fact: the group is the mean trace's name, the variable is the y-axis
title, and `± std` is the distance to the upper-bound trace. Also set
`hoverinfo="skip"` on the Upper/Lower Bound traces — they are construction
artefacts, and a tooltip on one reads like a measurement nobody took.

One detail that will cost you an afternoon otherwise: hand `customdata` over as a
**plain list**, not a numpy array. `plotly.io` serialises arrays as a base64
`{dtype, bdata}` blob that plotly.js does not decode for `customdata`, so the
tooltip renders `± NaN`. A test asserting "customdata is truthy" passes either
way — this was caught in a browser.

## Comparing picked cycles across cells

"Cycle 3 of cell A over cycle 7 of cell B" is a natural question and cellpy
has no direct expression for it — two things stand in the way, and both are
collect-side facts rather than bugs:

- **One cycle list per batch.** `CurveOptions(cycles=…)` / `IcaOptions(cycles=…)`
  apply to every cell in the batch. Collect the **union** and narrow afterwards.
- **Both layouts facet.** `layout="per_cell"` and `layout="per_cycle"` each put
  one cell or one cycle per panel; there is no `overlay`. And the colours follow
  the *cycle number*, so cycle 3 of two cells would draw in the same colour if
  you merely moved the traces onto one axis.

The frame is a plain polars table, so narrowing is one join. The cycle column
differs by family — `cycle_num` in the cycles frame, `cycle` in ICA / DVA —
and the `cell` values are the batch keys (label, name or id, de-duplicated):

```python
import polars as pl

collection = collect_cycles(batch, options=CurveOptions(cycles=(3, 7)))
wanted = pl.DataFrame({"cell": ["A", "B"], "cycle_num": [3, 7]})
collection.data = collection.data.join(wanted, on=["cell", "cycle_num"], how="semi")
figure = collection.plot(layout="per_cell")   # two facets, one curve each
```

For a true overlay, keep cellpy's traces and rewrite only the presentation:
point every trace at `x`/`y`, give each its own legend entry (the PX
hovertemplate already carries `cell=<label>`, so the identity survives the
rename), recolour **sequentially** rather than by cycle, and drop the facet
strips and extra axes. The app does exactly this in
`collect._overlay_facets`; see `.issueflows/04-designs-and-guides/compare-cells.md`
for the decision and [#169](https://github.com/cellpy/cellpy-simple-gui/issues/169)
for the request.

### Say when a cycle is not there

Narrowing to picked pairs has a trap: a pair that matches nothing just
disappears. If cell B has 21 cycles and you ask for its cycle 50, the semi-join
drops it silently, and if *no* requested cycle exists in any cell the
collectors do not even return an empty frame — `collect_cycles` fails with
`KeyError('cycle_num')` and `collect_ica` / `collect_dva` with
`AttributeError('cycle')` further down (painpoint §37). Neither message says
"no such cycle".

Check before and after collecting:

```python
requested = {"sf033": [1, 900]}                      # 900 is not in this file
have = {"sf033": set(cell.get_cycle_numbers())}
missing = {k: [c for c in v if c not in have[k]] for k, v in requested.items()}
existing = sorted({c for k, v in requested.items() for c in v if c in have[k]})
assert existing, "nothing to collect — say so instead of calling cellpy"

collection = collect_cycles(batch, options=CurveOptions(cycles=tuple(existing)))
present = set(map(tuple, collection.data.select(["cell", "cycle_num"]).unique().rows()))
unreadable = {
    k: [c for c in v if c in have[k] and (k, c) not in present] for k, v in requested.items()
}
print(missing, unreadable)                            # {'sf033': [900]} {'sf033': []}
```

"Not in data" and "could not be read" are different diagnoses — the second is a
cycle the file lists but that yields no curve rows (a truncated or corrupted
cycle). The app words both per cell (`core/cycle_report.py`), carries them in
`layout.meta.warnings`, and shows them in a strip above the chart; when
nothing can be drawn the empty figure says why
([#175](https://github.com/cellpy/cellpy-simple-gui/issues/175)).

## Single-cell plots

Two useful ones live outside the collect path, taking a cell directly:

```python
from cellpy.utils.plotutils import raw_plot

figure = raw_plot(cell, plot_type="voltage-current", backend="plotly", max_points=4000)
print(len(figure.data), "traces")
```

`max_points` matters more than it looks. Without it, one 155k-row demo cell
produced **7.35 MiB** of figure JSON; with it, 0.18 MiB. It also downsamples
silently, so if the number of points is scientifically relevant, say so — read
the drawn count off the figure and annotate it rather than assuming a stride:

```python
shown = max((len(t.x) for t in figure.data if t.x is not None), default=0)
total = len(cell.data.raw)
print(f"showing {shown:,} of {total:,} points")
```

The sibling, `cycle_info_plot`, has one sharp edge:

```python
from cellpy.utils.plotutils import cycle_info_plot

figure = cycle_info_plot(cell, cycle=[1, 2], backend="plotly", get_axes=True)
print(type(figure).__name__ if figure is not None else "None")
```

**`get_axes=True` is what returns the figure.** Without it, `cycle_info_plot`
returns `None` on the plotly backend and your chart is blank for no visible
reason.

## Where to go next

[Guide 4](04-exporting.md) turns the same collection into files — and the figure
above into PNG bytes without a temp file.

---

*Sources: `Collection.plot`, `cellpy.plotting.registry`,
`cellpy.plotting.collected`, `cellpy.utils.plotutils`. Traps from
[CELLPY_PAINPOINTS.md](../../CELLPY_PAINPOINTS.md) §28–§30 and
[cellpy-simple-gui#106](https://github.com/cellpy/cellpy-simple-gui/issues/106).*

==============================================================================
FILE: docs/guides/04-exporting.md
==============================================================================

# 4. Exporting data and figures

*You want files: the numbers behind a chart, the chart itself, or a whole cell.*

The rule that matters more than any API on this page: **export the collection you
plotted.** If the chart comes from one `Collection` and the CSV from a fresh
query, they will disagree eventually — different cycle cap, different half-cycle,
different grouping — and the person who notices will be someone comparing a
figure in a paper against its data.

## The numbers

`collection.data` is a polars DataFrame, so the formats are polars' own and there
is nothing to install:

```python
import warnings

from cellpy.collect import collect_summaries, from_cells
from cellpy.utils import example_data

warnings.simplefilter("ignore")

batch = from_cells({"sf033": example_data.cellpy_file()})
collection = collect_summaries(
    batch, columns=("charge_capacity_gravimetric", "discharge_capacity_gravimetric")
)

csv = collection.data.write_csv()
print(csv.splitlines()[0])
print(len(csv.splitlines()) - 1, "rows")
```

```text
cell,group,sub_group,group_label,label,cycle_num,charge_capacity_gravimetric,discharge_capacity_gravimetric
304 rows
```

In memory, which is what a web app wants — no temp file, no cleanup:

```python
import io

buffer = io.BytesIO()
collection.data.write_parquet(buffer)
print("parquet:", len(buffer.getvalue()), "bytes")

print("json:", len(collection.data.write_json()), "bytes")
print("xlsx: via", type(collection.data.to_pandas()).__name__ + ".to_excel")
```

Excel is the one that leaves polars: `collection.data.to_pandas().to_excel(buf,
index=False)`, which needs `openpyxl`. Everything else is native.

If you would rather cellpy wrote the files, it will:

```python
import tempfile
from pathlib import Path

with tempfile.TemporaryDirectory() as tmp:
    written = collection.save(Path(tmp), formats=("csv", "parquet"))
    print([p.name for p in written])
```

```text
['in_memory_summary.csv', 'in_memory_summary.parquet', 'in_memory_summary.meta.json']
```

`save` defaults to `("parquet", "csv")`; xlsx and json are supported too
([cellpy#789](https://github.com/jepegit/cellpy/issues/789)). It also writes a
`.meta.json` sidecar you did not ask for — welcome for provenance, surprising if
you are zipping the result and counting files.

## Matching the chart, in practice

Two cases where "export the collection you plotted" needs actual work.

**Differential curves.** dQ/dV and dV/dQ come from different collectors than
voltage curves, so an export routed on "the user is on the Cycles tab" will
happily give them the wrong numbers. Route on the *curve kind*:

```python
from cellpy.collect import collect_cycles, collect_dva, collect_ica
from cellpy.collect.options import CurveOptions, IcaOptions

COLLECTORS = {
    "voltage": lambda b, cycles: collect_cycles(b, options=CurveOptions(cycles=cycles)),
    "dqdv": lambda b, cycles: collect_ica(b, options=IcaOptions(cycles=cycles)),
    "dvdq": lambda b, cycles: collect_dva(b, options=IcaOptions(cycles=cycles)),
}

for kind, collect in COLLECTORS.items():
    data = collect(batch, (1, 5)).data
    print(f"{kind:<8} {data.height:>6} rows · {data.columns}")
```

```text
voltage    1829 rows · ['potential', 'capacity', 'cell', 'group', 'sub_group', 'cycle_num']
dqdv       1825 rows · ['cycle', 'direction', 'voltage', 'capacity', 'dqdv', 'cell', 'group', 'sub_group']
dvdq       1825 rows · ['cycle', 'direction', 'capacity', 'voltage', 'dvdq', 'cell', 'group', 'sub_group']
```

Three different column vocabularies for what a user thinks of as "the same two
cycles" — which is why routing the export on the tab rather than the kind gives
you a file with plausible headers and the wrong contents.

**Half-cycle selection.** The ICA/DVA plotters take a `direction` and draw one
half-cycle; the collected frame keeps both, in a `direction` column. If the chart
shows charge only, the export has to say so too:

```python
import polars as pl

def select_direction(collection, direction: str):
    """Mirror what the plotter drew. 'both' (or anything else) keeps everything."""
    want = (direction or "").lower()
    if want not in ("charge", "discharge") or "direction" not in collection.data.columns:
        return collection
    collection.data = collection.data.filter(
        pl.col("direction").str.to_lowercase() == want
    )
    return collection


ica = COLLECTORS["dqdv"](batch, (1, 5))
print(ica.data.height, "->", select_direction(ica, "charge").data.height, "rows")
```

## The figure

cellpy will encode a plotly figure to image bytes in-process — no temp file, no
subprocess ([cellpy#818](https://github.com/jepegit/cellpy/issues/818)):

```python
from cellpy.plotting import figures

print(sorted(figures.IMAGE_FORMATS))
print(figures.image_media_type("png"), figures.image_media_type("svg"))
```

```text
['jpeg', 'jpg', 'pdf', 'png', 'svg', 'webp']
image/png image/svg+xml
```

```python
figure = collection.plot()

try:
    data = figures.write_image(figure, "png", scale=2)
    print("png:", len(data), "bytes")
except Exception as exc:  # noqa: BLE001 - see below; this is the normal path
    print("no server-side export here:", type(exc).__name__)
```

`Collection.to_image(fmt, scale=)` does the same in one step if you have not
customised the figure.

### "kaleido is installed" and "figure export works" are different facts

This is the sharp edge, and it is not cellpy's doing. **kaleido 1.x renders by
driving a separate Chrome**, so the Python package being importable proves
nothing. Three states, and your error message has to tell them apart:

| State | What the user should be told |
|---|---|
| kaleido missing | install it — `uv sync --extra export` |
| kaleido present, no browser | install Chrome or Chromium; installing kaleido again will not help |
| something else | the real error |

Telling someone to install kaleido when kaleido is already there and the browser
is what is missing sends them in exactly the wrong direction — which is what
happens by default in a slim container:

```python
MISSING_KALEIDO = ("Static figure export needs kaleido. Install with: "
                   "uv sync --extra export.")
MISSING_BROWSER = ("Static figure export needs a Chrome or Chromium binary — "
                   "kaleido is installed but renders by driving a browser.")

def explain_export_failure(exc: BaseException) -> str:
    text = str(exc).lower()
    # Browser first: a browser failure often mentions kaleido too.
    if any(k in text for k in ("chrome", "chromium", "browser")):
        return MISSING_BROWSER
    if any(k in text for k in ("kaleido", "orca", "image export")):
        return MISSING_KALEIDO
    return f"Figure export failed ({exc})."


print(explain_export_failure(RuntimeError("BrowserFailedError: chrome exited")))
```

Note the ordering — browser before kaleido — because the browser failure text
usually names kaleido as well, and matching kaleido first gives the wrong answer.
`plotly_get_chrome` exiting 0 does not mean the browser works either; the only
honest check is to render something.

**Degrade, do not block.** Plotly's own toolbar has a camera button that saves a
PNG client-side, so a web app that cannot render server-side is inconvenienced,
not broken. Say that in the error rather than presenting a dead end.

## Whole cells

Different job: not a view, the cell itself.

```python
import tempfile
from pathlib import Path

cell = example_data.cellpy_file()
with tempfile.TemporaryDirectory() as tmp:
    target = Path(tmp) / "exported.cellpy"
    cell.save(target)
    print(target.name, "·", round(target.stat().st_size / 1e6, 1), "MB")
```

`cell.to_csv(datadir=…)` writes several files (raw, steps, summary), and
`cell.to_excel(filename=…)` writes one workbook. Because csv is multi-file and
users usually want one thing, zipping at the boundary is normal.

> **`.cellpy` writes are atomic** as of 2.1.2a4
> ([cellpy#845](https://github.com/jepegit/cellpy/issues/845)) — staged write plus
> `os.replace`, so an interrupted save no longer destroys the previous file. If
> your app saves a *set* of things (several cells plus a manifest), you still want
> your own staging: atomicity per file is not atomicity per project.

## Where to go next

[Guide 5](05-configuration.md) — where cellpy is reading its settings from, and
how to change them without writing into a file your users share with their
notebooks.

---

*Sources: `Collection.save` / `.to_image`, `cellpy.plotting.figures.write_image`,
`cellpy.plotting.figures.image_media_type`, `CellpyCell.save` / `.to_csv` /
`.to_excel`. Export traps from
[cellpy-simple-gui#135](https://github.com/cellpy/cellpy-simple-gui/issues/135).*

==============================================================================
FILE: docs/guides/05-configuration.md
==============================================================================

# 5. Configuration

*You want to know where cellpy is reading from — and change it without breaking
the machine it is running on.*

cellpy 2.1.2 replaced `parameters.prms` with a layered
[pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/)
stack. It is the single biggest app-facing improvement in the release, because it
makes a question most libraries leave unanswerable — *why does this setting have
this value?* — a normal API call.

## The layers

Later wins:

| # | Layer | Where |
|---|---|---|
| 1 | defaults | in the code |
| 2 | user config | `cellpy.toml` in the OS config directory (written by `cellpy setup`) |
| 3 | project config | a `cellpy.toml` found for the project |
| 4 | environment | `CELLPY_<SECTION>__<FIELD>` |
| 5 | runtime | `config.reload(overrides)` and `config.override(...)` |

```python
from cellpy import config

settings = config.get_config()
print(list(settings.model_fields))
```

```text
['paths', 'file_names', 'reader', 'db', 'db_cols', 'batch', 'instruments',
 'defaults', 'units', 'secrets']
```

Note `secrets` sitting there in plain sight. More on that below.

## Which file actually won, and where each value came from

Two calls, and together they answer nearly every "but it works on my machine":

```python
from cellpy import config

active = config.active_config_file()
print(type(active).__name__, "·", [f for f in dir(active) if not f.startswith("_")])
print("kind:", active.kind, "· shadowing a legacy file:", active.shadowed_legacy is not None)
```

`active_config_file()` reports the file the **loader** actually used, including
the case where a legacy `.conf` is being shadowed by a newer `cellpy.toml`. That
one detail removes a whole category of confusion — a user editing the file they
remember, watching nothing change
([cellpy#851](https://github.com/jepegit/cellpy/issues/851)).

Per-key provenance is `sources()`:

```python
from cellpy import config

sources = config.sources()
print(len(sources), "keys tracked")
print("layers seen:", sorted(set(sources.values())))
print("paths.outdatadir came from:", sources["paths.outdatadir"])
```

```text
150 keys tracked
layers seen: ['default', 'env', 'user_file']
paths.outdatadir came from: user_file
```

(Your layers will differ — that is the point.) If you are building anything with
a settings panel, show this. "Where is cellpy reading from?" is the first
question anyone asks when a path is wrong, and here it is answerable in one line
instead of by reasoning about precedence.

## Changing settings

**Process-wide, permanent for this run:**

```python
from cellpy import config

config.reload({"reader": {"cycle_mode": "anode"}})
print(config.get_config().reader.cycle_mode)
```

```text
anode
```

**Scoped, and isolated per thread:**

```python
from cellpy import config

with config.override(reader={"cycle_mode": "cathode"}):
    print("inside: ", config.get_config().reader.cycle_mode)
print("outside:", config.get_config().reader.cycle_mode)
```

```text
inside:  cathode
outside: anode
```

`override()` is contextvar-based since 2.1.2a3, so it isolates per thread *and*
per asyncio task, and nested blocks stack LIFO. It used to be process-global,
which meant two workers could silently swap each other's units mid-job
([cellpy#850](https://github.com/jepegit/cellpy/issues/850)). If you are running
cellpy in a thread pool, [guide 6](06-state-and-threading.md) is the one to read.

**By environment**, which is how you configure a container:

```text
CELLPY_PATHS__OUTDATADIR=/data
CELLPY_READER__CYCLE_MODE=anode
```

Double underscore between section and field. This is the layer to reach for in
deployment: no file to mount, no file to write.

## Never write the user's config file

`cellpy.toml` in the OS config directory is shared with that person's notebooks
and CLI. An app that writes there is reaching into a space it does not own, and
the effect shows up somewhere the user will not connect to your app.

Read it, show it, override it for your process — but let `cellpy setup` be the
only thing that writes it.

If you need settings to travel with a *project*, write a project-scoped
`cellpy.toml` and point the loader at it:

```python
from cellpy import config

options = config.LoadOptions(project_config_file=None)  # a real path in your app
print([f for f in config.LoadOptions.__dataclass_fields__])
```

```text
['user_config_file', 'project_config_file', 'env_file', 'cwd', 'skip_files',
 'skip_env', 'legacy_yaml_file']
```

And **write an allow-list of sections, not the whole config.** We pin only
`reader`, `units` and `defaults`. The two exclusions are the interesting part:

- **`paths` would bake this machine's layout into a portable project** — the
  project then only works where it was created, which is the opposite of the
  point.
- **omitting `instruments` and `db` means the file structurally cannot contain a
  credential.** Not "we scrub it": there is no field for it to be in.

That second one is worth doing even though cellpy's own dump scrubbing was fixed
([cellpy#849](https://github.com/jepegit/cellpy/issues/849), 2.1.2a3). A
structural guarantee does not depend on someone else's regex staying correct, and
it survives a config schema growing a new secret-ish field.

## Credentials

Same reasoning one level up. `secrets` is a section, and legacy instrument keys
like `SQL_PWD` can sit in the in-memory config even on a version that no longer
writes them to file. If your app displays configuration, mask them:

```python
CREDENTIALISH = ("pwd", "password", "secret", "token", "key", "uid", "user")


def masked(section: str, field: str, value):
    if section == "secrets" or any(k in field.lower() for k in CREDENTIALISH):
        return "••••" if value else None
    return value


print(masked("instruments", "SQL_PWD", "hunter2"), "·", masked("paths", "outdatadir", "/data"))
```

```text
•••• · /data
```

Masking in a UI is right regardless of what the library writes to disk — the
threat is a screenshot in a support ticket, not only a file on disk.

## The relative-path trap

One setting, three ways to lose, and it cost real time in both of this project's
deployment routes.

**(a) Some defaults are relative.** With no user config, `paths.examplesdir` is
`cellpy_data/examples` and `paths.filelogdir` is `cellpy_data/logs` — resolved
against the **process working directory**, not `$HOME`. For an installed
application the cwd is wherever the shortcut happened to start it, so two
correct-looking processes disagree about where the data lives.

**(b) `examplesdir` is resolved at import time.** `example_data.DATA_PATH` is
computed when the module is imported, and if the configured directory does not
*exist*, it falls back to `site-packages/cellpy/utils/data`. Setting
`CELLPY_PATHS__EXAMPLESDIR` is therefore not enough on its own — the directory
has to exist first, or the override is discarded with a `warnings.warn`.

**(c) The fallback target is the wrong place to write.** In a container it is
root-owned while the app runs unprivileged, so the zero-setup demo fails on a
permission error at the friendliest button in the product. In a frozen Windows
app it is inside the install directory: a first run wrote ~9 MB there, and the
uninstaller left it behind, because the installer had never put it there.

The fix is to anchor the writable paths yourself, **before anything imports
`example_data`**:

```python
from pathlib import Path

WRITABLE_PATHS = (
    ("examplesdir", "CELLPY_PATHS__EXAMPLESDIR"),
    ("filelogdir", "CELLPY_PATHS__FILELOGDIR"),
)


def anchor(paths_section, home: Path) -> dict[str, Path]:
    """Make relative path settings absolute under `home`, and create them."""
    resolved = {}
    for field, _env in WRITABLE_PATHS:
        value = str(getattr(paths_section, field, "") or "")
        if not value or "://" in value:      # rawdatadir can be scp://…; leave it
            continue
        path = Path(value)
        resolved[field] = path if path.is_absolute() else (home / path)
    return resolved


from cellpy import config

for field, path in anchor(config.get_config().paths, Path.home()).items():
    print(field, "->", "absolute" if path.is_absolute() else "RELATIVE")
```

```text
examplesdir -> absolute
filelogdir -> absolute
```

In the real thing you then set the env var, call `config.reload()`, and `mkdir`
each one. Two details learned by getting them wrong:

- **Fix all of them, not the one that hurt.** Anchoring `examplesdir` alone left
  `cellpy_debug.log`, `cellpy_errors.log` and `cellpy_info.log` in the install
  directory — same defect, different field.
- **Control the cwd before you check.** Our first fix created the directory from
  the same relative path and merely moved the mess into the source repo. The
  install folder only *looked* clean because that is where the test was running.

*(Raised upstream as [cellpy#938](https://github.com/jepegit/cellpy/issues/938):
an absolute default, and creating the directory rather than falling back. A
config value that is accepted and then quietly ignored is very hard to debug from
outside the library.)*

## Where to go next

[Guide 6](06-state-and-threading.md) — the config is process-global, and so are
several other things. What that means once you have more than one thread.

---

*Sources: `cellpy.config.get_config` / `sources` / `override` / `reload` /
`active_config_file` / `LoadOptions`. Traps from
[CELLPY_PAINPOINTS.md](../../CELLPY_PAINPOINTS.md) §22–§24 and §33.*

==============================================================================
FILE: docs/guides/06-state-and-threading.md
==============================================================================

# 6. Process state and threading

*You want to load a 200 MB file without freezing the UI, and you want to know
what that costs you.*

Loading a cell takes seconds. Collecting across twenty of them takes longer. In
anything with a user interface that has to happen off the main thread, which
makes the question on this page unavoidable: **what in cellpy is per-call, and
what is per-process?**

## The short answer

| Thing | Scope | Safe from a worker thread? |
|---|---|---|
| `cellpy.get(...)` | per call | yes — everything it needs is an argument |
| `collect_*` / `Collection.plot` | per call | yes |
| `config.override(...)` | per thread / per asyncio task | yes — that is what it is for |
| `config.reload(...)` | **process** | only if a process-wide change is what you mean |
| `config.set_load_options(...)` | **process** | same |
| `example_data.DATA_PATH` | **process**, fixed at import | see [guide 5](05-configuration.md) |

The good news is that the data path — load, collect, plot — takes its inputs as
arguments and returns new objects. It is the *settings* that are shared.

## `override` is per thread; `reload` is not

Since 2.1.2a3 `config.override()` is built on `contextvars`, so two workers can
hold different values at the same time and nested blocks stack LIFO:

```python
import threading
import warnings

from cellpy import config

warnings.simplefilter("ignore")

seen = {}
both_inside = threading.Barrier(2)


def worker(name, mode):
    with config.override(reader={"cycle_mode": mode}):
        both_inside.wait()          # force the overlap; without it, no race to see
        seen[name] = config.get_config().reader.cycle_mode


threads = [threading.Thread(target=worker, args=a) for a in (("a", "anode"), ("b", "cathode"))]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(dict(sorted(seen.items())))   # sorted: the threads finish in either order
```

```text
{'a': 'anode', 'b': 'cathode'}
```

The barrier is the load-bearing part of that test. Without it the two blocks
might never overlap, and a broken implementation would pass — which is exactly
how this was verified when
[cellpy#850](https://github.com/jepegit/cellpy/issues/850) was fixed. Before that
fix `override` was process-global, so a worker could change another worker's
units mid-job and the result was wrong rather than crashed.

`reload()` and `set_load_options()` are still process-global, deliberately.
Reach for them when you mean "this process now works this way" — switching to a
project's configuration, say — and for nothing finer-grained.

That distinction is a design decision you have to make too. In this app a
*project* switch calls `reload()` on the request thread rather than
`override()` per job, because a project switch really is process-wide: the user
changed what the application is working on, not what one job is doing.

## Your own singletons are the real constraint

cellpy's process-global surface is small. An app's is usually larger, and it is
worth being deliberate about rather than discovering later.

This one holds four: the in-memory cell library, the job manager, the active
project configuration, and an instrument-discovery cache. Together they mean
**two browsers pointed at one process share one library, one job queue and one
cellpy configuration**.

That is not a bug here — the app is deployed one instance per user, and the
whole design leans on it. But it is load-bearing, so it is written down: anything
that later wants to serve several people from one process starts by fixing those
four, plus cellpy's process-global config underneath them.

If you are building something multi-tenant, decide this on day one. Retrofitting
a request-scoped library into code that assumes a module-level dict is a rewrite,
not a refactor.

## A worker pool that survives its workers

Two things learned the hard way, both about `concurrent.futures`.

**Do not subclass `ThreadPoolExecutor` to change how workers start.** A daemon
pool built by copying CPython's private `_worker` and `_initializer` broke on
Python 3.14, when those internals changed shape:

```pycon
AttributeError: '_DaemonThreadPoolExecutor' object has no attribute '_initializer'
```

The failure is not the copy going stale — that was foreseeable — it is that it
only appears on a Python version nobody had tested yet. If you need behaviour the
standard pool does not offer, write the twenty lines: a queue, some daemon
threads, and a sentinel to stop them.

```python
import queue
import threading

_STOP = object()


class DaemonPool:
    """Threads that never block interpreter exit, and survive a failing job."""

    def __init__(self, workers: int = 2):
        self._queue: queue.Queue = queue.Queue()
        self._threads = [
            threading.Thread(target=self._run, daemon=True) for _ in range(workers)
        ]
        for t in self._threads:
            t.start()

    def _run(self):
        while True:
            item = self._queue.get()
            if item is _STOP:
                return
            fn, args = item
            try:
                fn(*args)
            except Exception:  # noqa: BLE001 - one bad job must not kill the worker
                pass

    def submit(self, fn, *args):
        self._queue.put((fn, args))

    def shutdown(self):
        for _ in self._threads:
            self._queue.put(_STOP)
        # Deliberately no join(): shutdown must not be able to hang exit.


pool = DaemonPool()
done = threading.Event()
pool.submit(lambda: (_ for _ in ()).throw(RuntimeError("boom")))  # a failing job
pool.submit(done.set)                                             # still runs
print("survived a failing job:", done.wait(timeout=5))
pool.shutdown()
```

```text
survived a failing job: True
```

Three properties worth stating, because each was a bug at some point: workers are
**daemon** threads so a hung job cannot stop the app exiting; a worker **survives
a failing job** rather than dying quietly and shrinking the pool to nothing; and
`shutdown` **never joins**, so shutdown cannot be the thing that hangs.

**Run CI on the newest Python, not only the supported one.** The 3.14 breakage
above existed for a while under a fully green build. A separate job pinned to the
newest release is cheap and is the only thing that would have caught it.

## Progress and cancellation

cellpy's calls are synchronous and do not take a progress callback, so anything
the user sees is granularity you impose from outside — per file, per cell, per
collect step. That is usually enough: "loading 3 of 12" is more useful than a
smooth bar over one opaque call.

Cancellation is the same story. There is no way to interrupt `cellpy.get` in
flight, so a cancel flag can only be honoured *between* units of work. Say so in
the UI — a Cancel button that takes effect after the current file is honest; one
that appears to stop instantly and does not is worse than no button.

## Where to go next

[Guide 7](07-delegation.md) — the running list of what cellpy will do for you, so
you can stop writing the parts it already owns.

---

*Sources: `cellpy.config.override` (contextvars),
`cellpy.config.reload` / `set_load_options`. Traps from
[CELLPY_PAINPOINTS.md](../../CELLPY_PAINPOINTS.md) §23 and
[cellpy-simple-gui#139](https://github.com/cellpy/cellpy-simple-gui/issues/139).*

==============================================================================
FILE: docs/guides/07-delegation.md
==============================================================================

# 7. What cellpy will and will not do for you

*You are about to write some code. Should you?*

This is the guide that saves the most time, because the expensive mistakes in
this project were never hard bugs — they were weeks spent maintaining something
cellpy already owned, or would own two releases later.

It is distilled from
[`cellpy-delegation-inventory.md`](../../.issueflows/04-designs-and-guides/cellpy-delegation-inventory.md),
which is the same document kept live for one specific app. Written against
**cellpy 2.1.3**.

## cellpy owns these — do not reimplement them

| You might write | cellpy already has |
|---|---|
| a batch shim around in-memory cells | `collect.from_cells(cells, groups=…, selected=…)` |
| per-cycle summaries across cells | `collect_summaries(batch, columns=…)` |
| group averaging with mean ± std | `group_it=True`, then `plot(spread=True)` |
| a "was this averaged?" flag | `Collection.is_grouped` |
| voltage-curve extraction per cycle | `collect_cycles` + `CurveOptions(cycles=…, mode=…, method=…)` |
| dQ/dV and dV/dQ, across cells | `collect_ica` / `collect_dva` + `IcaOptions` |
| a table of which plot needs which options | `family.summary_options(hdr)` |
| a hard-coded plot menu | `registry.families(entry_point="summary_plot")` |
| figure theming, panel heights, facet labels | `plot(layout_updates=…, height_per_panel=…)` |
| PNG/SVG/PDF encoding via a temp file | `figures.write_image(fig, fmt, scale=) -> bytes` |
| csv / parquet / json / xlsx writers | `collection.data.write_*`, `collection.save(...)` |
| an instrument list | `cellpy.list_instruments()` |
| an ingestion form per instrument | `cellpy.instrument_meta_schema(instrument)` |
| a cheap metadata peek | `cellpy.read_meta(path)` — cellpy files only |
| a staged-write wrapper for `.cellpy` | atomic since 2.1.2a4 |
| a full `make_summary()` after a metadata edit | `cell.refresh_after(fields=…)` |
| point-limiting for raw plots | `raw_plot(cell, max_points=…, cycles=…)` |

Two of those are worth their own sentence.

**`from_cells` replaced a hand-rolled batch shim** and was the largest single
deletion in this project ([cellpy#787](https://github.com/jepegit/cellpy/issues/787)).

**`family.summary_options(hdr)` replaced the app's out-of-band knowledge** of
which families need `partition_by_cv` ([cellpy#868](https://github.com/jepegit/cellpy/issues/868)).
It arrived better than requested: the ask was one accessor, and the fix added
capability flags and `entry_point` tagging as well. Measured on the demo cells,
that took the app from 8 of 25 plot families working to 15 of 20.

## cellpy does not do these, and probably should not

Not gaps — different jobs:

- **Chrome.** Colorways, a figure-theme preference, legend truncation for long
  cell names, the right-hand margin that stops a legend squashing the plot.
- **A cell library.** Which cells are loaded, what they are called, which are
  selected, which have unsaved changes. cellpy has no "has this cell changed?"
  signal, so if you want to skip rewriting an unchanged file you track it
  yourself. `(size, mtime_ns)` is a reasonable freshness check — it catches a file
  rewritten behind your back without re-reading megabytes on every save, and it
  will not catch a tamper that preserves both. That is the honest limit, and it
  is fine for a single-user desktop app.
- **Projects.** Saving a set of cells plus a manifest as a portable folder.
- **Jobs, progress and cancellation.** See [guide 6](06-state-and-threading.md).
- **Sandboxing.** If your app takes paths over a network, deciding what may be
  read is yours. cellpy will open whatever you hand it, correctly.

## Currently worth working around

Two open issues, both narrow, both with a workaround that should be deleted when
upstream lands. Keeping that list short — and *actually deleting* the entries —
is the difference between a thin app and a thick one.

| # | What | Workaround |
|---|---|---|
| [#875](https://github.com/jepegit/cellpy/issues/875) | `spread_plot` traces carry no `hovertemplate` at all | rebuild hover from the figure; `hoverinfo="skip"` on the band edges |

One came off this list in the writing of it: [#874](https://github.com/jepegit/cellpy/issues/874)
(an unknown `layout=` accepted silently) closed in 2.1.3, and the app's
translation shim went with it. What did *not* change was the test — it asserted
the resulting trace type rather than the shim's return value, so it kept meaning
something when the responsibility moved upstream. That is the whole of habit 3
below, and it is worth copying.

Plus three unfiled frictions where the app simply forwards a knob rather than
working around anything: summary y-labels omit units, the cycles plotter ignores
the collect `mode` for `x_unit`, and summary facet order ignores collect column
order under group-averaging.

## How to keep the list shrinking

Four habits, in the order they pay off.

**1. File it, with a reproduction.** 29 issues came out of this project and 25
are closed. Nearly every one was a five-line script showing the surprising
output. The two that came back *better than asked* were the two that explained
what the app was trying to do, rather than only what was wrong.

**2. Closed is not released.** This caught us twice. Verify against the installed
package before deleting a workaround:

```python
import warnings

from cellpy.plotting import registry
from cellpy.utils import example_data

warnings.simplefilter("ignore")

hdr = example_data.cellpy_file().schema.summary
family = registry.get("capacities_gravimetric_split_constant_voltage")

# The #868 fix, verified rather than assumed: options carry the CV flag,
# and transforms are callables rather than a nested mapping.
options = family.summary_options(hdr)
print("partition_by_cv:", options.partition_by_cv)
print("transforms are callable:", all(callable(t) for t in (options.transforms or ())))
```

```text
partition_by_cv: True
transforms are callable: True
```

**3. Test the delegation, not the workaround.** When a workaround goes, the test
that guarded it should become a test that the delegation *works* — otherwise the
next regression is silent. The film translation is the clearest example: the test
asserts `histogram2d` traces, so it keeps meaning something whether the fix is
ours or upstream's.

**4. Watch for silent success.** The worst failures in this whole project looked
like passes:

- an import job reported `status: "done"` and imported **zero** cells, because
  the failure lived in the result payload rather than the status;
- instrument discovery returned **11 of 13** loaders and logged nothing a user
  would see;
- `layout="film"` drew a perfectly plausible *line* plot;
- a `customdata` array serialised to base64 and rendered `± NaN` in a tooltip
  while a test asserting "customdata is truthy" passed.

In every case the code was wrong in a way that produced *plausible* output, and
in every case the fix was at the level of the check rather than the code: assert
on `added`/`errors` rather than job status, assert the trace type rather than
that a figure exists, look at the thing in a browser. Green meaning working is
not free; it is a property you have to design for.

## The shape that turned out right

For anyone starting: build a `Collection` and let cellpy plot it.

Grouping, spread, per-cell cycle isolation and multi-format export then stay
consistent with cellpy instead of drifting away from it, and every upstream fix
arrives as a deletion in your code rather than a migration. `core/collect.py` in
this app is substantially *shorter* than it was at 2.1.1, and nearly all the
difference is logic cellpy now owns.

---

*Sources:
[`cellpy-delegation-inventory.md`](../../.issueflows/04-designs-and-guides/cellpy-delegation-inventory.md)
and [`CELLPY_PAINPOINTS.md`](../../CELLPY_PAINPOINTS.md) (29 filed, 25 closed).*

==============================================================================
FILE: docs/api-reference.md
==============================================================================

# cellpy API surface

The calls that matter when building on cellpy, with real signatures and
one line each of what a signature cannot tell you — so you do not have to
grep `site-packages` to find out that `family.summary_options(hdr)` exists.

Generated from the installed **cellpy 2.1.3** by
[`tools/gen_api_reference.py`](../tools/gen_api_reference.py); do not edit by
hand. The prose is curated, the signatures are introspected, and
`tests/test_api_reference.py` fails if this file drifts from the installed
package.

For worked examples see [`docs/guides/`](guides/README.md); for a running
app in one file see [`examples/starter/`](../examples/starter/).

## Loading

Getting cells into memory — [guide 1](guides/01-loading-cells.md).

```python
cellpy.get(filename=None, instrument=None, instrument_file=None, cellpy_file=None, cycle_mode=None, mass: str | numbers.Number = None, nominal_capacity: str | numbers.Number = None, nom_cap_specifics=None, loading=None, area: str | numbers.Number = None, estimate_area=True, logging_mode=None, custom_log_dir=None, custom_log_config_path=None, auto_pick_cellpy_format=True, auto_summary=True, units=None, step_kwargs=None, summary_kwargs=None, selector=None, testing=False, refuse_copying=False, initialize=False, debug=False, **kwargs)
```
The one entry point. Only `filename` is required; `mass` / `area` / `nominal_capacity` accept unit strings such as `"0.47 mg"`.

```python
cellpy.list_instruments() -> List[Dict[str, Any]]
```
Loaders registered at runtime: `{id, label, models, suffixes}`. Registered is not the same as usable on this machine.

```python
cellpy.read_meta(path)
```
Metadata without loading the data — **cellpy files only**; a raw file gives an HDF5 traceback.

```python
cellpy.instrument_meta_schema(instrument: str | None = None) -> Dict[str, Any]
```
Fields an instrument wants (`name`, `required`, `type`, `unit`, `maps_to`, `help`) — enough to generate an ingestion form.

```python
cellpy.utils.example_data.cellpy_file(testing: bool = False) -> cellpy.readers.cellreader.CellpyCell
```
A loaded demo cell (304 cycles). Downloads once, then caches.

```python
cellpy.utils.example_data.cellpy_file_path() -> pathlib.Path
```
Path to the same file, for when you want to call `cellpy.get` yourself.

```python
cellpy.utils.example_data.rate_file()
```
**A path, not a cell** — unlike `cellpy_file()`. Passing it where a cell is expected fails silently.

```python
cellpy.utils.example_data.neware_file_path() -> pathlib.Path
```
A raw Neware export (`.csv`) that loads with no external tooling.

```python
cellpy.utils.example_data.arbin_file_path() -> pathlib.Path
```
A raw Arbin `.res` — needs mdbtools or the Access driver to load.

```python
cellpy.filefinder.find_in_raw_file_directory(raw_file_dir: cellpy.internals.otherpath.OtherPath | pathlib.Path | str | None = None, project_dir: cellpy.internals.otherpath.OtherPath | pathlib.Path | str | None = None, extension: str | None = None, glob_txt: str | None = None, allow_error_level: int | None = 3)
```
Recursive file listing of a local **or remote** (`sftp://`) folder — the remote-aware stand-in for a glob. `extension` is given without the dot; `glob_txt` narrows by name; pass `allow_error_level=1` so SSH failures raise instead of returning an empty list.

## One cell

`CellpyCell` — what you get back from `cellpy.get`.

```python
CellpyCell.get_cycle_numbers(steptable=None, rate=None, rate_on=None, rate_std=None, rate_agg='first', inverse=False)
```
The cycle numbers present, optionally filtered by rate.

```python
CellpyCell.get_cap(cycle=None, cycles=None, method='back-and-forth', insert_nan=None, shift=0.0, categorical_column=False, label_cycle_number=False, split=False, interpolated=False, dx=0.1, number_of_points=None, ignore_errors=True, inter_cycle_shift=True, interpolate_along_cap=False, capacity_then_voltage=False, mode='gravimetric', mass=None, area=None, volume=None, cycle_mode=None, usteps=None, dynamic=False, **kwargs)
```
Capacity/voltage curve for one cycle or several, as **pandas**.

```python
CellpyCell.save(filename, force=False, overwrite=None, extension='cellpy', ensure_step_table=None, ensure_summary_table=None, cellpy_file_format=None)
```
Write a `.cellpy` file. Atomic since 2.1.2a4 — staged write plus `os.replace`.

```python
CellpyCell.to_csv(datadir=None, sep=None, cycles=False, raw=True, summary=True, shifted=False, method=None, shift=0.0, last_cycle=None)
```
Write raw / steps / summary as separate CSVs into a directory.

```python
CellpyCell.to_excel(filename=None, cycles=None, raw=False, steps=True, nice=True, get_cap_kwargs=None, to_excel_kwargs=None)
```
Write one workbook.

```python
CellpyCell.refresh_after(fields=None, **kwargs)
```
Rebuild only what a metadata edit invalidated, instead of a full `make_summary()`.

## Cells into a Collection

The bridge and the four collectors — [guide 2](guides/02-collections.md).

```python
cellpy.collect.from_cells(cells, **kwargs) -> 'Batch'
```
**The call that matters.** Turns `{label: CellpyCell}` into a real `Batch` with no journal on disk. Values that are not cells are dropped silently — validate first.

```python
cellpy.collect.collect_summaries(batch: 'Any', options: 'SummaryOptions | None' = None, **overrides) -> 'Collection'
```
Per-cycle summary values across cells. `group_it=True` averages within groups **and changes the frame's schema**.

```python
cellpy.collect.collect_cycles(batch: 'Any', options: 'CurveOptions | None' = None, **overrides) -> 'Collection'
```
Voltage/capacity curves for chosen cycles, isolated per cell.

```python
cellpy.collect.collect_ica(batch: 'Any', options: 'IcaOptions | None' = None, **overrides) -> 'Collection'
```
dQ/dV. Keeps both half-cycles in a `direction` column.

```python
cellpy.collect.collect_dva(batch: 'Any', options: 'IcaOptions | None' = None, **overrides) -> 'Collection'
```
dV/dQ. Same shape as `collect_ica` (2.1.2a4).

```python
cellpy.collect.load_collection(path: 'Path | str') -> 'Collection'
```
Read a saved collection back.

## Collect options

Dataclasses passed as `options=`; `.replace(...)` returns a modified copy.

```python
cellpy.collect.options.SummaryOptions(columns, max_cycle, remove_last, only_selected, rate, rate_on, rate_std, rate_column, rate_inverse, rate_inverted, partition_by_cv, normalize_cycles, group_it, average_method, custom_group_labels, replace_inf_with_nan, replace_extremes_with_nan, low_limit, high_limit, transforms)
```
For `collect_summaries`. Usually obtained from `family.summary_options(hdr)` rather than built by hand.

```python
cellpy.collect.options.CurveOptions(cycles, rate, rate_on, rate_std, inverse, mode, method, transforms)
```
For `collect_cycles` — `cycles`, plus `mode` and `method` so you need not slice afterwards.

```python
cellpy.collect.options.IcaOptions(cycles, voltage_resolution, capacity_resolution, transforms)
```
For `collect_ica` / `collect_dva` — `cycles` and the resolutions.

## The Collection

A polars frame that knows how to draw itself — [guides 3](guides/03-plotting.md) and [4](guides/04-exporting.md).

```python
Collection.plot(*, family_kind: 'str | None' = None, **kwargs)
```
Returns a real plotly `Figure`. Takes `layout_updates`, `height_per_panel`, `spread`, `layout` **and `kind`**.

```python
Collection.save(directory: 'Path | str | None' = None, formats: 'tuple[str, ...]' = ('parquet', 'csv')) -> 'list[Path]'
```
Write the frame; defaults to parquet + csv, and adds a `.meta.json` sidecar.

```python
Collection.to_image(fmt: 'str' = 'png', *, scale: 'float' = 1.0, **plot_kwargs) -> 'bytes'
```
Collection straight to image bytes.

```python
Collection.to_wide(values: 'str', index: 'str' = 'cycle_num', columns: 'str' = 'cell') -> 'pl.DataFrame'
```
One column per cell, for a spreadsheet.

```python
Collection.is_grouped  ->  property
```
Whether these are averaged series. Decides which schema `.data` has, and whether `spread=True` means anything.

## The plot registry

Build a menu instead of maintaining one — [guide 3](guides/03-plotting.md).

```python
cellpy.plotting.registry.families(*, entry_point: 'str | None' = None) -> 'list[tuple[str, str]]'
```
`[(name, description)]`. **Pass `entry_point="summary_plot"`** or you list families that can never work in a summary menu.

```python
cellpy.plotting.registry.get(name: 'str') -> 'PlotFamily'
```
One `PlotFamily` by name.

```python
PlotFamily.summary_options(hdr: 'Any', *, norm_factor: 'float | None' = None) -> 'Any'
```
**The accessor that matters.** A ready `SummaryOptions` — columns, CV flag, transforms. Judge availability on `.columns` of *this*.

```python
PlotFamily.columns(hdr: 'Any') -> 'list[str]'
```
The columns the family *draws*, including ones collect manufactures. Not an availability check.

## Single-cell plots

Outside the collect path; these take a cell.

```python
cellpy.utils.plotutils.raw_plot(cell, y=None, y_label=None, x=None, x_label=None, title=None, backend: str | None = None, plot_type='voltage-current', double_y=True, cycles=None, max_points: int | None = None, **kwargs)
```
Raw traces. **Set `max_points`** — one demo cell is 7.35 MiB of figure JSON without it, 0.18 MiB with.

```python
cellpy.utils.plotutils.cycle_info_plot(cell, cycle=None, get_axes=False, backend: str | None = None, t_unit='hours', v_unit='V', i_unit='mA', **kwargs)
```
Raw traces annotated with step/cycle info. **Needs `get_axes=True`** or it returns `None` on the plotly backend.

```python
cellpy.utils.plotutils.dva_plot(cell, cycles=None, direction='both', options=None, *, backend: str | None = None, title=None, colormap='viridis', width=800, height=600, figsize=(6, 4), x_range=None, y_range=None, plotly_template=None, return_data=False, **kwargs)
```
Single-cell dV/dQ. Prefer `collect_dva` unless you specifically want one cell.

## Figures to bytes

In-process encoding, no temp file — [guide 4](guides/04-exporting.md).

```python
cellpy.plotting.figures.write_image(figure, fmt: 'str' = 'png', *, scale: 'float' = 1.0, **kwargs) -> 'bytes'
```
plotly figure to image bytes. Raises if kaleido is missing **or** if kaleido cannot find a browser — different problems, different advice.

```python
cellpy.plotting.figures.image_media_type(fmt: 'str') -> 'str'
```
MIME type for a format, for an HTTP response.

## Configuration

The 2.1.2 layered stack — [guide 5](guides/05-configuration.md).

```python
cellpy.config.get_config() -> 'CellpyConfig'
```
The resolved settings: `paths`, `reader`, `units`, `instruments`, `db`, `secrets`, …

```python
cellpy.config.sources() -> 'dict[str, str]'
```
`{"section.field": layer}` — per-key provenance. This is how you answer "where did this value come from?"

```python
cellpy.config.active_config_file(options: 'LoadOptions | None' = None) -> 'ActiveConfigFile'
```
Which file the loader actually used, including a legacy `.conf` shadowed by a `cellpy.toml`.

```python
cellpy.config.override(**sections: 'Any') -> 'Iterator[CellpyConfig]'
```
Scoped overrides, isolated **per thread and per asyncio task** (contextvars), stacking LIFO.

```python
cellpy.config.reload(overrides: 'dict[str, Any] | None' = None, *, options: 'LoadOptions | None' = None) -> 'CellpyConfig'
```
Re-resolve everything. **Process-global** — use it when you mean process-wide.

```python
cellpy.config.LoadOptions(user_config_file, project_config_file, env_file, cwd, skip_files, skip_env, legacy_yaml_file)
```
Where to load config from; `project_config_file` is how you pin a project's settings.

## Attributes worth knowing

| | Type | |
|---|---|---|
| `cell.data.summary` | pandas | per-cycle values — the source of every summary column name |
| `cell.data.raw` | pandas | the raw measurement frame |
| `cell.data.steps` | pandas | the step table |
| `cell.schema.summary` | `CycleCols` | the summary column vocabulary — this is the `hdr` argument the registry wants |
| `cell.cell_name / .mass / .nominal_capacity / .active_electrode_area` |  | cell metadata |
| `collection.data` | polars | the numbers behind the figure |

The frames on a **cell** are pandas; the frame on a **collection** is
polars. Cross the boundary explicitly with `.to_pandas()`, at the edge of
your code rather than in the middle of it.

==============================================================================
FILE: examples/starter/README.md
==============================================================================

# cellpy starter app

One file. Load battery cells, plot them, download the numbers.

```bash
uv run examples/starter/app.py
```

Then open <http://127.0.0.1:8000> and press **Load demo cell**.

[`app.py`](app.py) carries its own dependencies in a PEP 723 header, so it also
runs on its own, outside this repository:

```bash
uv run --script app.py
```

Copy it somewhere, rename it, start deleting things you do not need. That is
what it is for.

## What this is

The shortest honest path from "I have cell files" to "I have a chart and a CSV".

cellpy-simple-gui — the app in the rest of this repository — is a reference
implementation, and at ~8,000 lines it is a bad thing to start from. This is the
starting point: ~340 lines, of which about a quarter is the web page. Small
enough to read in one sitting, and small enough to keep in front of you (or in a
coding agent's context) while you work on something else.

**Not in it, on purpose:** saved projects, background jobs, browser upload,
authentication, themes, dev mode, instrument metadata forms. Every one of those
is a real feature with a real cost, and you should add them when you need them.

## The four calls

The whole cellpy story, and the four sections of `app.py`:

| Call | In | Out |
|---|---|---|
| `cellpy.get(filename=…)` | one file | one `CellpyCell` |
| `from_cells({name: cell})` | cells you already hold | a `Batch` |
| `collect_summaries(batch, columns=…)` | a `Batch` | a `Collection` |
| `collection.plot()` | a `Collection` | a plotly `Figure` |

The load-bearing one is **`from_cells`**. It takes cells that are already in
memory and hands back a real cellpy `Batch`, which means grouping, group
averaging, spread bands, cycle selection and multi-format export are cellpy's
job rather than yours. Assembling frames by hand instead is the most common way
to end up maintaining a worse copy of cellpy.

A `Collection` is a tidy [polars](https://pola.rs) frame (`.data`) that knows
how to draw itself (`.plot()`). Both the chart and the CSV in `app.py` come from
the same `Collection`, so the download always matches what is on screen.

## Adding a plot

`SUMMARY_PLOTS` maps a menu entry to the `cell.data.summary` columns behind it.
Adding a plot is adding a line — nothing else in the file knows the names:

```python
SUMMARY_PLOTS: dict[str, tuple[str, ...]] = {
    ...
    "C-rate": ("charge_c_rate", "discharge_c_rate"),
}
```

Reload the page. The menu, the chart, the CSV and the axis labels all follow.

To find out what you can put there, ask a cell you have loaded:

```python
list(cell.data.summary.columns)   # 53 of them on the bundled demo cell
```

Two things about those names that cost time if you learn them the hard way:

- **The suffix is the capacity basis.** `discharge_capacity_gravimetric` is per
  gram, `_areal` is per cm², and no suffix is absolute. They are different
  columns, not a display setting, and the gravimetric ones are only meaningful
  if the cell knows its active mass (`cellpy.get(..., mass=…)`).
- **A column the data does not have draws an empty chart rather than raising.**
  `app.py` checks first and says which ones are missing; if you drop that check,
  put something else in its place.

Plots that are not a column selection — voltage curves, dQ/dV, dV/dQ — are a
different *collector*, not a different column list. `collect_cycles` is in
`app.py`; `collect_ica` and `collect_dva` are its siblings and take the same
shape of options.

## What to add next, and where to look

[`docs/guides/`](../../docs/guides/) goes deeper on each step above — loading,
collections, plotting, export, configuration, threading — organised by task, with
every code block executed by CI.

When you outgrow this, the reference implementation has each piece in one place:

| You want | Look at |
|---|---|
| cellpy's own plot families, not a hand-written column table | `src/cellpy_simple_gui/core/collect.py` (`registry_plot_types`, `family_summary_options`) |
| dQ/dV and dV/dQ | `core/collect.py` (`ica_collection`, `dva_collection`) |
| Saving and reopening a set of cells | `core/projects.py` |
| Long loads that must not block the page | `api/jobs.py` |
| Serving it to a browser that is not on this machine | `core/paths.py`, `core/uploads.py`, and [`docs/deployment.md`](../../docs/deployment.md) |
| Static PNG/SVG/PDF export | `core/export.py` |

## Honest limitations

- **Localhost only, no authentication.** `POST /api/cells` takes a path and
  reads it, so anything that can reach the port can read any file the process
  can. That is fine on your own machine and nowhere else. Serving it for real
  means closing that surface first — see `core/paths.py` and
  [`docs/deployment.md`](../../docs/deployment.md), which exist because this app
  had to solve it.
- **One user, one process.** Loaded cells are a module-level dict, and cellpy's
  configuration is process-global besides. Two browsers on one process share
  both. This is a deliberate simplification, not an oversight — but it is the
  first thing to fix if more than one person will use it at once.
- **plotly.js comes from a CDN,** so the page needs network on first load. The
  full app vendors it instead.
- **The demo cell downloads once** from cellpy's example-data repository and is
  then cached under `~/cellpy_data`.

==============================================================================
FILE: examples/starter/app.py
==============================================================================

```python
# /// script
# requires-python = ">=3.13"
# dependencies = [
#     "cellpy>=2.1.3",
#     "fastapi>=0.115",
#     "uvicorn>=0.30",
#     "plotly>=5.22",
# ]
# ///
"""A cellpy app in one file: load cells, plot them, export the numbers.

    uv run examples/starter/app.py        # then open http://127.0.0.1:8000

This is a **starting point**, not a small version of cellpy-simple-gui. It is
deliberately the shortest path from "I have cell files" to "I have a chart and a
CSV", so that everything you add on top is yours rather than ours.

The whole cellpy story is four calls, and they are the four sections below:

    cellpy.get(...)          one file  -> one CellpyCell
    from_cells({...})        many cells -> a Batch
    collect_summaries(batch) a Batch    -> a Collection (a tidy polars frame)
    collection.plot()        a Collection -> a plotly Figure

The single thing worth knowing before you read on: **you do not build the
Batch.** ``from_cells`` takes cells you already hold in memory and hands back a
real cellpy Batch, so grouping, cycle selection, group averaging, spread bands
and multi-format export all come from cellpy rather than from you. Reaching past
it and assembling frames by hand is the most common way to end up maintaining a
worse copy of cellpy.

What this file leaves out on purpose: saved projects, background jobs, upload,
authentication, themes. Those are most of why the full app is twenty times the
size. Add them when you need them, not before.

The server binds to 127.0.0.1 and has no authentication. That is fine for a tool
on your own machine and *only* there — the moment it is reachable from a
network, ``/api/cells`` is arbitrary file read on the host.
"""

from __future__ import annotations

import warnings
from pathlib import Path
from typing import Any

import plotly.io as pio
import uvicorn
from fastapi import FastAPI, HTTPException
from fastapi.responses import HTMLResponse, Response

# --------------------------------------------------------------------------- #
# 1. What you can plot — this table is the thing you edit first
# --------------------------------------------------------------------------- #

#: Plot name -> the ``cell.data.summary`` columns behind it. Adding a plot is
#: adding a line here; nothing else in this file knows the names.
#:
#: The ``_gravimetric`` suffix is the capacity *basis*. cellpy also computes
#: ``_areal``, and unsuffixed columns are absolute — so "discharge capacity per
#: gram" and "discharge capacity per cm²" are different columns, not a setting.
SUMMARY_PLOTS: dict[str, tuple[str, ...]] = {
    "Capacity": ("charge_capacity_gravimetric", "discharge_capacity_gravimetric"),
    "Coulombic efficiency": ("coulombic_efficiency",),
    "End voltages": ("potential_end_charge", "potential_end_discharge"),
    "Internal resistance": ("ir_charge", "ir_discharge"),
}

#: Voltage curves are a different *collector*, not a different column set, so
#: they get a name of their own rather than an entry above.
CYCLE_CURVES = "Voltage curves"

#: Loaded cells, by display name. A module global because this app serves one
#: person on one machine — which is a decision, not an oversight. Two browsers
#: pointed at one process share this dict, and cellpy's own configuration is
#: process-global too, so anything multi-user starts by fixing both.
CELLS: dict[str, Any] = {}


# --------------------------------------------------------------------------- #
# 2. Getting cells into memory
# --------------------------------------------------------------------------- #


def load_example() -> str:
    """Load cellpy's bundled demo cell. Downloads once, then caches."""
    from cellpy.utils import example_data

    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        cell = example_data.cellpy_file()
    return _remember("demo cell", cell)


def load_file(path: str, instrument: str | None = None, mass: float | None = None) -> str:
    """Load one file. ``instrument=None`` lets cellpy decide from the suffix.

    ``mass`` is the active-material mass in mg. Without it the gravimetric
    columns are computed against whatever mass the file carries (often 1.0),
    so the numbers are still *there* — just not per gram of your material.
    """
    import cellpy

    kwargs: dict[str, Any] = {"filename": path}
    if instrument:
        kwargs["instrument"] = instrument
    if mass is not None:
        kwargs["mass"] = mass
    return _remember(Path(path).stem, cellpy.get(**kwargs))


def _remember(name: str, cell: Any) -> str:
    """Store a cell under a name that is not already taken."""
    unique, n = name, 1
    while unique in CELLS:
        n += 1
        unique = f"{name} ({n})"
    CELLS[unique] = cell
    return unique


def instruments() -> list[str]:
    """Every loader cellpy can offer on this machine.

    Discovered at runtime, so the list depends on the installed cellpy — and a
    loader being listed does not promise it will work here: Arbin ``.res`` is an
    Access database and needs a system reader (mdbtools, or Microsoft's Access
    Database Engine on Windows) that most machines do not have.
    """
    import cellpy

    return sorted(entry["id"] for entry in cellpy.list_instruments())


# --------------------------------------------------------------------------- #
# 3. Cells -> Collection -> figure -> bytes
# --------------------------------------------------------------------------- #


def collection_for(plot: str, *, cycles: tuple[int, ...] = (1, 5, 10, 20)):
    """The cellpy ``Collection`` behind one plot name.

    A Collection is a tidy `polars` frame (``.data``) plus enough context to
    draw itself (``.plot()``). Both plots and exports below come from here, so
    the CSV you download always matches the chart you are looking at.
    """
    from cellpy.collect import collect_cycles, collect_summaries, from_cells
    from cellpy.collect.options import CurveOptions

    if not CELLS:
        raise HTTPException(400, "Load a cell first.")

    if plot == CYCLE_CURVES:
        columns = None
    else:
        columns = SUMMARY_PLOTS.get(plot)
        if columns is None:
            raise HTTPException(404, f"No plot called {plot!r}.")
        missing = _missing(columns)
        if missing:
            # Collecting a column the summary does not carry draws an empty chart
            # rather than raising, so say it here instead.
            raise HTTPException(400, "These cells have no " + ", ".join(missing) + ".")

    try:
        batch = from_cells(CELLS)
    except ValueError as exc:
        raise HTTPException(400, str(exc)) from exc

    if plot == CYCLE_CURVES:
        return collect_cycles(batch, options=CurveOptions(cycles=cycles))
    return collect_summaries(batch, columns=columns)


def _missing(columns: tuple[str, ...]) -> list[str]:
    """Which of ``columns`` none of the loaded cells actually has."""
    have: set[str] = set()
    for cell in CELLS.values():
        have |= set(cell.data.summary.columns)
    return [c for c in columns if c not in have]


def figure_json(plot: str, **kwargs) -> str:
    """Plotly figure JSON for a plot name.

    ``Collection.plot()`` returns a real ``plotly.graph_objects.Figure``, so
    anything you know about plotly applies — restyle it, add traces, whatever.
    Two knobs worth knowing: ``group_it=True`` on the collect call averages
    cells that share a group, and ``spread=True`` here draws the ±1σ band.
    """
    # One facet per summary column, matching the two-trace walkthrough.
    # cellpy ≥2.1.5 otherwise stacks charge and discharge on one panel and
    # adds a Direction legend.
    extra = {} if plot == CYCLE_CURVES else {"combine_directions": False}
    figure = collection_for(plot, **kwargs).plot(**extra)
    return pio.to_json(figure)


def export_csv(plot: str, **kwargs) -> bytes:
    """The collected frame as CSV — the numbers behind the chart.

    ``collection.data`` is polars, so ``write_parquet`` / ``write_json`` are
    right there too, and ``.to_pandas()`` if you would rather have pandas.
    """
    return collection_for(plot, **kwargs).data.write_csv().encode("utf-8")


# --------------------------------------------------------------------------- #
# 4. The web bit
# --------------------------------------------------------------------------- #

app = FastAPI(title="cellpy starter")


@app.get("/", response_class=HTMLResponse)
def index() -> str:
    return PAGE


@app.get("/api/state")
def state() -> dict:
    return {
        "cells": list(CELLS),
        "plots": [*SUMMARY_PLOTS, CYCLE_CURVES],
        "instruments": instruments(),
    }


@app.post("/api/cells")
def add_cells(body: dict) -> dict:
    """Load the demo cell (``{"example": true}``) or files (``{"paths": [...]}``)."""
    if body.get("example"):
        load_example()
    for path in body.get("paths") or []:
        try:
            load_file(path, body.get("instrument"), body.get("mass"))
        except Exception as exc:  # noqa: BLE001 - the reason belongs on screen
            raise HTTPException(400, f"{Path(path).name}: {exc}") from exc
    return {"cells": list(CELLS)}


@app.delete("/api/cells")
def clear_cells() -> dict:
    CELLS.clear()
    return {"cells": []}


@app.get("/api/figure")
def figure(plot: str) -> Response:
    return Response(figure_json(plot), media_type="application/json")


@app.get("/api/export.csv")
def export(plot: str) -> Response:
    return Response(
        export_csv(plot),
        media_type="text/csv",
        headers={"Content-Disposition": f'attachment; filename="{plot}.csv"'},
    )


# --------------------------------------------------------------------------- #
# 5. One page, no build step
# --------------------------------------------------------------------------- #

PAGE = """<!doctype html>
<meta charset="utf-8"><title>cellpy starter</title>
<script src="https://cdn.plot.ly/plotly-2.32.0.min.js"></script>
<style>
  body { font: 15px/1.5 system-ui, sans-serif; margin: 2rem; max-width: 60rem; }
  header { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
  #chart { height: 30rem; margin-top: 1rem; }
  #cells { color: #667; margin-top: .5rem; }
  input { flex: 1; min-width: 14rem; }
</style>

<h1>cellpy starter</h1>
<header>
  <button onclick="load({example: true})">Load demo cell</button>
  <input id="path" placeholder="…or a path to a cell file on this machine">
  <button onclick="load({paths: [path.value]})">Load</button>
  <button onclick="send('DELETE')">Clear</button>
</header>
<div id="cells"></div>

<header style="margin-top:1rem">
  <select id="plot" onchange="draw()"></select>
  <a id="csv" href="#">Download CSV</a>
</header>
<div id="chart"></div>

<script>
// What is loaded lives here, not in the text on screen: a status line you also
// read state back out of drifts the moment anything writes to it twice.
let loaded = [];

const api = async (url, opts) => {
  const r = await fetch(url, opts);
  if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
  return r.json();
};
const send = (method, body) =>
  api('/api/cells', {method, headers: {'Content-Type': 'application/json'},
                     body: body && JSON.stringify(body)}).then(refresh);
const load = (body) => send('POST', body).catch(e => say(e.message));

function say(problem) {
  cells.textContent =
    (loaded.length ? 'Loaded: ' + loaded.join(', ') : 'No cells loaded.') +
    (problem ? ' — ' + problem : '');
}

async function refresh() {
  const s = await api('/api/state');
  loaded = s.cells;
  if (!plot.options.length)
    plot.append(...s.plots.map(p => new Option(p, p)));
  draw();
}

let drawing = 0;

async function draw() {
  const mine = ++drawing;
  csv.href = '/api/export.csv?plot=' + encodeURIComponent(plot.value);
  if (!loaded.length) { say(); return Plotly.purge(chart); }
  // Collecting a few hundred cycles takes a moment, and a chart that has not
  // caught up yet is the previous plot wearing the new plot's name.
  say('drawing…');
  try {
    const fig = await api('/api/figure?plot=' + encodeURIComponent(plot.value));
    if (mine !== drawing) return;  // someone changed the menu while we waited
    Plotly.react(chart, fig.data, fig.layout, {responsive: true});
    say();
  } catch (e) {
    if (mine !== drawing) return;
    Plotly.purge(chart);
    say(e.message);
  }
}

refresh();
</script>
"""


if __name__ == "__main__":
    # 127.0.0.1 on purpose: see the module docstring.
    uvicorn.run(app, host="127.0.0.1", port=8000)
```
