> For the complete documentation index, see [llms.txt](https://mapir.gitbook.io/chloros/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://mapir.gitbook.io/chloros/reference-cli-and-sdk/sdk-reference.md).

# Chloros Python SDK Reference

**Version:** 1.2.0 **Generated:** 2026-07-29 19:19 · **Revised:** 2026-08-30 **Package:** `chloros-sdk` (PyPI) **Audience:** Optimised for LLM consumption; human-readable. **Scope:** Every public class, function, and helper exposed by `import chloros_sdk`, with copy-pasteable examples covering image processing, single-camera control, synchronized arrays, DAQ sensors, and project automation.

If you only need the highlights, jump to:

* [Installation & Quickstart](#installation)
* [Smart-Connect for LATTICE Arrays](#smart-connect-for-lattice-cameras)
* [DAQ Sensor Sessions](#daq-sensor-sessions)
* [Project Automation](#project-automation--chlorosproject)
* [Smart-AE / Smart-Capture](#smart-ae--smart-capture)

***

## Architecture in 60 Seconds

The SDK is a thin Python layer over the Chloros backend (the same Flask server the desktop GUI and CLI use). For automation you import `chloros_sdk` and call high-level methods; under the hood, every call becomes an HTTP request to the local backend on port 5000 — `http://127.0.0.1:5000/api/...` (deliberately not `localhost`, which resolves to `::1` first on Windows and costs \~2 s per request against an IPv4-only backend). The backend owns the hardware pool — cameras, DAQ sensors, alignment profiles, frame buffers — so SDK scripts can co-exist with the GUI without fighting for serial ports or NIC bandwidth.

There are three surfaces you'll use:

1. **`ChlorosLocal` + free functions** (`process_folder`, `process_lattice_capture`) — Image-processing pipeline. Run an entire folder through calibration / debayer / index export from one Python call.
2. **Smart-connect handles** (`connect_camera`, `connect_array`, `connect_daq_sensor`) — Open a persistent backend session for live hardware. Same "smart-prep" flow as the GUI: network probe, tier auto-pick, PTP, AE seeding, GPIO trigger config.
3. **`ChlorosProject` / `open_project`** — Load a saved project (folder with `cameras.json` + `sensors.json` + `project.json`), connect everything at once, and drive captures with named handles.

Surfaces 1 and 2 **auto-start a local backend** if one isn't already listening (the same bundled binary the GUI/CLI spawn) — so a bare script works from a fresh shell without you starting a backend first. Pass `auto_start_backend=False` to opt out (e.g. when pointing at a remote backend, which is never spawned). See [Backend Auto-Start](#backend-auto-start). Surface 3 behaves differently: `open_project()` takes no `auto_start_backend` parameter, and `connect_all()` never spawns a backend — it probes `http://127.0.0.1:5000` once and, if nothing answers, silently falls back to direct (backend-free) `lattice_sdk` device control. Only `proj.process()` and `stream(..., overlays=True)` lazily construct a `ChlorosLocal()` (which does auto-start).

All three are auth-gated: run `chloros-cli login` once on the machine, or sign in via the desktop GUI. SDK calls without a valid session raise `ChlorosAuthenticationError`.

Requirements:

* Python 3.7+ (as declared by the package; developed/tested on 3.10)
* Chloros Desktop installed locally (the backend binary ships inside the installer)
* Active Chloros+ login. The SDK/CLI floor is **Copper** tier or higher (Copper / Bronze / Silver / Gold); the free **Iron** tier has no SDK/CLI access. This is enforced **server-side**: every SDK/CLI-flagged request must carry both a live session and a paid plan, or the backend returns `403` with `error_code: PLAN_UPGRADE_REQUIRED` (surfaced as `ChlorosLicenseError` by `ChlorosLocal`, and as `ChlorosConnectError` by the `connect_*` helpers). A logged-out caller gets `401` / `AUTH_REQUIRED` (`ChlorosAuthenticationError`) instead — the two are distinct because re-running `chloros-cli login` fixes the first and cannot fix the second.
* Offline use is supported within the plan's grace period: the tier is read from the server-validation cache (5 min) or the signed, machine-bound license cache (30 days for monthly plans, to subscription expiry for yearly). When that grace lapses the plan resolves to free and SDK/CLI access stops until the machine can reach the server once. `chloros-cli status` (`GET /api/license-status`) stays reachable on the free tier so the reason is visible — it is the only SDK/CLI route exempt from the tier gate.
* Windows 10/11 64-bit, **Ubuntu 22.04 LTS or newer**, or Jetson (JetPack 6). Ubuntu 20.04 is **not** supported: the `.deb`'s dependencies are derived from what the backend links against, including `libc6 (>= 2.34)`, and focal ships glibc 2.31.

***

## Installation

The Python SDK is a thin Python layer over the Chloros backend. For everything beyond a few DAQ-only workflows, you need the **Chloros desktop package installed locally** (Windows installer or Linux `.deb`) — that's what provides the backend binary, the Arena SDK runtime for LATTICE cameras, and the calibration bundles.

Latest downloads: [`https://mapir.gitbook.io/chloros/download`](https://mapir.gitbook.io/chloros/download)

### Step 1 — Install the Chloros platform package

#### Windows (.exe)

1. Download `Chloros-Setup-x.y.z.exe` from the download page.
2. Run the installer and follow the wizard. Default install path is `C:\Program Files\MAPIR\Chloros\`.
3. Launch Chloros at least once and sign in with your Chloros+ account.

#### Linux amd64 (.deb)

```bash
sudo dpkg -i chloros-amd64.deb
sudo apt-get install -f         # only if dpkg reports missing dependencies
chloros-cli --version
chloros-cli login user@example.com 'YourPassword'
```

#### Linux arm64 — Jetson (JetPack 6)

```bash
sudo dpkg -i chloros-arm64-jp6.deb
sudo apt-get install -f
chloros-cli --version
chloros-cli login user@example.com 'YourPassword'
```

### Step 2 — Install the Python SDK

**The Chloros installer ships a matching SDK wheel.** Every Windows installer and Linux .deb places a `chloros_sdk-X.Y.Z-py3-none-any.whl` on disk that exactly matches the GUI / CLI / backend version. You don't have to chase PyPI to stay in sync.

#### Windows

The installer auto-runs `pip install` against the bundled wheel using your system Python (`py.exe` launcher preferred, falls back to `python -m pip`). No action required — `import chloros_sdk` works in your Python environment after a successful install. If no Python is on the box, the installer silently skips this step and the GUI + CLI keep working.

#### Linux (.deb)

The .deb places the wheel at `/usr/lib/chloros/sdk/`. The `postinst` prints the exact command — PEP 668 distros refuse global pip writes by default, so we don't auto-install:

```bash
pip install --user /usr/lib/chloros/sdk/chloros_sdk-*.whl
```

For air-gapped Jetson deploys this is fully offline — the wheel is already on disk.

#### Public PyPI

For pip-only hosts (no Chloros desktop package installed; remote-backend or DAQ-only workflows):

```bash
pip install chloros-sdk
```

PyPI is updated on release-version installer builds, so the published wheel matches the latest stable release. Dev builds (e.g. `1.1.4.dev1`) only ship via the bundled installer wheel.

#### Verify

```python
import chloros_sdk
print(chloros_sdk.__version__)
print("CAMERA_AVAILABLE =", chloros_sdk.CAMERA_AVAILABLE)
print("DAQ_AVAILABLE    =", chloros_sdk.DAQ_AVAILABLE)
print("PROJECT_AVAILABLE =", chloros_sdk.PROJECT_AVAILABLE)
```

> **Chloros+ subscription required.** All SDK calls require an active Chloros+ login. Run `chloros-cli login user@example.com 'YourPassword'` once per machine; credentials are cached in `~/.chloros/`.

### Do I Need the Desktop Package?

The pip package alone is **not** enough for most workflows. Here's what each SDK surface needs:

| SDK Surface                                                                                              | Needs Desktop Package?            | Why                                                                                                                                                                                                 |
| -------------------------------------------------------------------------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ChlorosLocal`, `process_folder`, `process_lattice_capture`                                              | **Yes**                           | Auto-starts the backend binary at `/usr/lib/chloros/chloros-backend` (Linux) or `C:\Program Files\MAPIR\Chloros\…` (Windows).                                                                       |
| `connect_camera`, `connect_array`, `connect_daq_sensor`, `analyze_array_network`, `list_*`, `discover_*` | **Yes** (local) **/ No** (remote) | Pure HTTP clients over the backend. Local backend → desktop package required. Remote backend → `backend_url=` **through a tunnel** (see Remote-Backend Mode — shipped backends bind loopback only). |
| `ChlorosProject` / `open_project`                                                                        | **Yes**                           | Drives saved projects through the backend.                                                                                                                                                          |
| Direct LATTICE classes (`LatticeCamera`, `CameraPool`, `Calibration`, `DLS`, …)                          | **Yes**                           | Need the Arena SDK native runtime that ships inside the desktop package. `CAMERA_AVAILABLE` is `False` at import otherwise.                                                                         |
| Direct DAQ classes (`DAQUSensor`, `DAQMSensor`, `DAQESensor`, `SensorFleet`, `discover_all`)             | **No**                            | Pure Python over pyserial/bleak/zeroconf. A pip-only environment can drive DAQs end-to-end.                                                                                                         |

### Remote-Backend Mode (pip-only host, via tunnel)

> **The shipped backend is not reachable over the LAN.** Production builds bind loopback only (both loopback families) and hard-refuse the only non-loopback mode (`CHLOROS_CLOUD_MODE`), so `backend_url="http://<lan-ip>:5000"` **cannot work against an installed Chloros** — that pattern only ever worked against a source/dev backend. To drive a backend on another machine, forward its loopback port yourself and point the SDK at the tunnel:

```bash
# on the pip-only host: forward local 5000 to the Chloros machine's loopback
ssh -N -L 5000:127.0.0.1:5000 user@chloros-host
```

```python
import chloros_sdk

BACKEND = "http://127.0.0.1:5000"   # the tunnel endpoint

chloros_sdk.connect_camera("213800234", backend_url=BACKEND)
chloros_sdk.connect_array(serials, backend_url=BACKEND)
chloros_sdk.connect_daq_sensor(eth_host="daq-e-1.local", backend_url=BACKEND)
```

Headless / CI / robotics hosts can keep one machine with the full desktop install as the "Chloros server" and `pip install chloros-sdk` everywhere else — but the transport between them is the user-arranged tunnel above, not a direct LAN URL.

> **Known limitation — `ChlorosLocal` is not pip-only-capable.** `ChlorosLocal(backend_url=BACKEND)` currently resolves a local backend binary in its constructor *before* probing the URL, and raises `ChlorosBackendError` ("Chloros backend not found…") when no desktop package is installed — even with a reachable remote backend. Only the smart-connect surface above (`connect_camera` / `connect_array` / `connect_daq_sensor`, plus `analyze_array_network` and the `list_*` / `discover_*` helpers) works from a pip-only host.

### DAQ-Only Workflow (pip-only host)

If you only need DAQ sensors and don't touch LATTICE cameras or image processing, the pip package is self-contained:

```bash
pip install chloros-sdk
```

```python
from chloros_sdk import DAQUSensor, DAQMSensor, DAQESensor, discover_all

for d in discover_all(timeout=3.0):
    print(d.model, d.display, d.address)   # USB serials: d.extra.get("serial_number")

sensor = DAQUSensor(port="/dev/ttyUSB0")
sensor.connect()
sensor.start_streaming()
```

No backend, no .deb, no Chloros+ login required for direct-hardware DAQ work.

***

## Quickstart

```python
import chloros_sdk

# === Image processing ===
results = chloros_sdk.process_folder(
    "C:/DroneImages/Flight001",
    indices=["NDVI", "NDRE", "GNDVI"],
)

# === Live LATTICE single-cam ===
with chloros_sdk.connect_camera("213800234") as cam:
    cam.set_settings(exposure_time=10000, gain=0.0)
    cam.capture("output/")

# === Live LATTICE synchronized array (GUI smart-prep flow) ===
with chloros_sdk.connect_array(
        ["213800234", "214000533", "214701288", "214701292"]) as arr:
    arr.capture("output/", processing="reflectance")

# === Live DAQ spectral sensor ===
with chloros_sdk.connect_daq_sensor() as daq:    # smart-detect USB / BLE / ETH
    for frame in daq.latest(n=5):
        print(frame["spectrum"][:10])

# === Drive a saved project end-to-end ===
proj = chloros_sdk.open_project("/path/to/project")
proj.connect_all()
proj.arrays["main_rig"].capture("output/", processing="reflectance")
proj.disconnect_all()
```

***

## Top-Level API Index

```python
import chloros_sdk

# === Image processing (full pipeline) ===
chloros_sdk.ChlorosLocal                          # class
chloros_sdk.process_folder(...)                   # one-shot helper
chloros_sdk.process_lattice_capture(...)          # LATTICE-friendly defaults
chloros_sdk.read_image_audit_tags(path)           # post-run audit

# === Live cameras (persistent backend pool) ===
chloros_sdk.connect_camera(serial, ...)           # → CameraSession
chloros_sdk.connect_array(serials, ...)           # → ArraySession (smart-prep)
chloros_sdk.attach_array(serials_or_id, ...)      # → ArraySession (attach without re-connecting)
chloros_sdk.list_cameras()
chloros_sdk.list_arrays()
chloros_sdk.discover_lattice_cameras()
chloros_sdk.analyze_array_network(...)            # network capability + recommendation
chloros_sdk.CaptureResult                         # list subclass returned by ArraySession.capture
chloros_sdk.RecorderHandle                        # handle for an array record()/burst() job

# === Live DAQ sensors (persistent backend pool) ===
chloros_sdk.connect_daq_sensor(...)               # → DAQSensorSession
chloros_sdk.discover_daq_sensors()                # scan USB/BLE/ETH (finds a DAQ-M MAC)
chloros_sdk.list_daq_sensors()

# === Project lifecycle ===
chloros_sdk.open_project(path)                    # → ChlorosProject
chloros_sdk.ChlorosProject                        # class
chloros_sdk.AlignmentSpec                         # dataclass
chloros_sdk.ArrayHandle, CameraHandle, SensorHandle

# === Direct-hardware (no-backend) classes (from lattice_sdk / daq_sdk) ===
chloros_sdk.LatticeCamera, CameraSettings, PRESETS, CameraPool
chloros_sdk.Calibration, CalibrationCoefficients, FilterModel, list_filters
chloros_sdk.DLS, NetworkDiagnostics
chloros_sdk.DAQUSensor, DAQMSensor, DAQESensor, SensorFleet, discover_all

# === Exceptions ===
chloros_sdk.ChlorosError                          # base
chloros_sdk.ChlorosBackendError
chloros_sdk.ChlorosLicenseError
chloros_sdk.ChlorosConnectionError
chloros_sdk.ChlorosProcessingError
chloros_sdk.ChlorosAuthenticationError
chloros_sdk.ChlorosConfigurationError
chloros_sdk.ChlorosConnectError                   # raised by smart-connect surface
chloros_sdk.LatticeError, CameraNotFoundError, ...  # from lattice_sdk

# === Availability flags ===
chloros_sdk.CAMERA_AVAILABLE     # True iff lattice_sdk imported cleanly
chloros_sdk.DAQ_AVAILABLE        # True iff daq_sdk imported cleanly
chloros_sdk.PROJECT_AVAILABLE    # True iff ChlorosProject deps available
```

***

## Image Processing — `ChlorosLocal`

The headline pipeline class. Spawns the backend on first use, creates / configures projects, monitors progress, returns post-run summaries.

### Constructor

```python
ChlorosLocal(
    api_url="http://127.0.0.1:5000",   # backend URL (also: backend_url=)
    auto_start_backend=True,            # spawn backend if not running
    backend_exe=None,                   # override backend binary path
    timeout=30,                         # request timeout seconds
    backend_startup_timeout=60,         # backend boot timeout
    processing_timeout=14400,           # hard cap on process() (4 h)
    processing_stuck_timeout=1800,      # no-progress threshold (30 min)
)
```

### Methods

| Method                                                                                                                                                                                                                                                                                       | Description                                                                                                                                                                                                                                                                                                                                                                                                             |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `create_project(project_name, camera=None)`                                                                                                                                                                                                                                                  | Create a new project (optionally with a camera template like `"Survey3N_RGN"`).                                                                                                                                                                                                                                                                                                                                         |
| `import_images(folder_path, recursive=False)`                                                                                                                                                                                                                                                | Import RAW/TIF/JPG/DNG images **and `.daq` light-sensor recordings**. Returns `count` (images) and `scan_count` (recordings). Warns only if the folder holds neither.                                                                                                                                                                                                                                                   |
| `export_light_sensor(daq=True, csv=True)`                                                                                                                                                                                                                                                    | Write calibrated `.daq` + `.csv` for every light-sensor recording on the project, into `<project>/Light Sensor/`. See [Light-Sensor Recordings](#light-sensor-recordings--calibrated-daq--csv).                                                                                                                                                                                                                         |
| `configure(debayer=..., vignette_correction=..., reflectance_calibration=..., indices=[...], export_format=..., ppk=..., daq_log_path=..., input_level=..., radiometric_output=..., array_alignment=..., array_alignment_crop=..., array_alignment_interpolation=..., custom_settings=None)` | Set processing knobs.                                                                                                                                                                                                                                                                                                                                                                                                   |
| `process(mode="parallel", wait=True, progress_callback=None, poll_interval=2.0)`                                                                                                                                                                                                             | Run the pipeline. Returns `{"status": "complete", "async": False}`, plus a `summary` key when the backend provides one — see [Post-Run Summary & Hints](#post-run-summary--hints).                                                                                                                                                                                                                                      |
| `get_config()` / `get_status()` / `status()`                                                                                                                                                                                                                                                 | Inspect backend state.                                                                                                                                                                                                                                                                                                                                                                                                  |
| `logout()`                                                                                                                                                                                                                                                                                   | Clear cached credentials.                                                                                                                                                                                                                                                                                                                                                                                               |
| `shutdown_backend()`                                                                                                                                                                                                                                                                         | Terminate the backend (if SDK-started).                                                                                                                                                                                                                                                                                                                                                                                 |
| `discover_cameras()`                                                                                                                                                                                                                                                                         | Discover LATTICE cameras **via this instance's backend** (`/api/camera/discover`). Returns a list of dicts (`serial`, `model`, `ip`, …) — same shape the GUI/CLI see. Empty list if none found or backend unreachable.                                                                                                                                                                                                  |
| `camera_capture(output_dir, format="tiff", **settings)`                                                                                                                                                                                                                                      | Capture a single frame **through the backend** (auto-started by this handle) so it gets the same prep as the GUI/CLI (12-bit default, pool reuse, embedded cal metadata). Resolve the target with `serial=` or `device_index=`; pass `exposure`/`gain`/`pixel_format`/`preset` as `**settings`. Returns the legacy metadata dict (`filepath`, `width`, `height`, `pixel_format`, `exposure_time`, `gain`, `timestamp`). |
| `camera_stream(serial, *, fps=10.0, overlay=None, decode=True, connect_timeout=10.0, read_timeout=15.0)`                                                                                                                                                                                     | Yield overlay-composited preview frames from a pooled camera — thin MJPEG client over the backend's `/api/camera/<serial>/stream-annotated` route (zebra / grid / crosshair / histogram / peaking / spot drawn server-side). `decode=True` yields BGR arrays; `False` yields raw JPEG bytes. Also reachable per-project as `ChlorosProject.stream(overlays=True)`.                                                      |

Use as a context manager for guaranteed cleanup:

```python
with chloros_sdk.ChlorosLocal() as cl:
    cl.create_project("FieldA_2026-05-26", camera="Survey3N_RGN")
    cl.import_images("C:/DroneImages/Flight001")
    cl.configure(
        vignette_correction=True,
        reflectance_calibration=True,
        indices=["NDVI", "NDRE", "GNDVI"],
        export_format="TIFF (16-bit)",
    )
    results = cl.process(mode="parallel", wait=True)
print(results["summary"])
```

### Light-Sensor Recordings — calibrated `.daq` + `.csv`

A DAQ-U / DAQ-M / DAQ-E can be recorded **without** its calibration bundle. That is what the public [`chloros_scripts`](https://github.com/mapircamera/chloros_scripts) recorders (`record_daq.py`) do by default: they write raw sensor counts and stamp the file so Chloros fetches that sensor's factory calibration **by serial** — local cache first, then MAPIR Cloud — and applies it on import.

Chloros writes the result back out as two products per recording, under `<project>/Light Sensor/`:

| Product                             | What it is                                                                                                                                                                                                  |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `<name>_calibrated.daq`             | The reprocessable archive — same schema as a live recording, now declaring the bundle that produced it. Re-importing it does **not** calibrate it a second time.                                            |
| `<name>_calibrated.csv`             | Spectral irradiance in W/m²/nm on the sensor's own wavelength grid, one row per reading, plus photometric columns (total power, photopic/scotopic lux, PPFD and its blue/green/red split, peak wavelength). |
| `<name>_raw.daq` / `<name>_raw.csv` | **Bundle-less sensors only (DAQ-A).** Raw spectral sensor counts — *not* irradiance. See below.                                                                                                             |

`process()` performs this export as one of its stages. It does **not** require imagery: a light sensor flown on its own is a first-class workflow, and such a project has zero images by construction.

**DAQ-A recordings export as raw counts.** The DAQ-A family predates the per-serial bundle system and has no bundle to fetch — it is calibrated in the field against a reflectance target instead, which is why it never needed one. Those recordings export under a `_raw` stem rather than `_calibrated`: a different filename rather than a flag inside the file, because the claim has to survive being emailed on as a bare name. The `.csv` header states `raw spectral sensor counts (NOT irradiance)` and warns that the values are comparable **within** the file — exactly what target-based calibration uses them for — and not across sensors. The power-dependent photometric columns (total power, photopic/scotopic lux, PPFD) come back **NULL** rather than integrated from counts.

A DAQ-U / DAQ-M / DAQ-E whose bundle simply could not be fetched is still **skipped**, not written raw: there the bundle exists and "reconnect and reprocess" is real advice.

Legacy **v1.01 / v1.02** recordings (a DAQ-A-SD writes these) carry no per-reading epoch, only the file's write time. The image↔downwelling matcher still refuses them — matching a frame against a write time would be wrong invisibly — but the exporter reads them, and the CSV prints `clock=daq_created_on` so the product states which clock it is on.

```python
import chloros_sdk

with chloros_sdk.ChlorosLocal() as cl:
    cl.create_project("DAQ-U_2026-08-26")
    cl.import_images("C:/Flights/raw_daq")     # .daq only — no camera involved
    result = cl.export_light_sensor()          # or just cl.process()

for rec in result["exported"]:
    print(rec["csv"])
for rec in result["skipped"]:
    print("skipped", rec["source"], "--", rec["reason"])
```

A recording whose calibration bundle cannot be fetched (offline, or a sensor with no calibration on file) is reported under `skipped` **with the reason**. It is never written out as a "calibrated" file holding raw counts — connect to the internet and re-run, and the export completes.

### Progress Callbacks

```python
def show_progress(percent, message):
    print(f"[{percent:3d}%] {message}")

with chloros_sdk.ChlorosLocal() as cl:
    cl.create_project("FieldA")
    cl.import_images("C:/DroneImages/Flight001")
    cl.configure(indices=["NDVI"])
    cl.process(progress_callback=show_progress, poll_interval=1.0)
```

### Post-Run Summary & Hints

On completion, `process()` fetches `GET /api/processing-summary` and attaches the body as `result["summary"]`. The fetch is best-effort and never blocks a successful return — if the summary is unavailable, `process()` falls back to the plain `{"status": "complete", "async": False}` shape. Each entry in `summary["hints"]` — full sentences with the suggested remediation, e.g. why a run produced zero output — is also re-emitted as a Python `UserWarning`, so 0-output runs are self-diagnosing even if you never inspect the dict:

```python
result = cl.process()
for hint in result.get("summary", {}).get("hints", []):
    print("HINT:", hint)
# hints also arrive on the warnings channel:
#   python -W always::UserWarning your_script.py
```

`summary["totals"]` is the machine-readable half:

| Key                                                  | What it counts                                                                                                                                                                                                  |
| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `models`                                             | Camera groups in the run.                                                                                                                                                                                       |
| `images_in_groups`                                   | Source images across those groups.                                                                                                                                                                              |
| `targets_found`                                      | Reflectance targets detected.                                                                                                                                                                                   |
| `images_calibrated`                                  | Images the run calibrated.                                                                                                                                                                                      |
| `exported_files`                                     | **Image product files the run wrote.**                                                                                                                                                                          |
| `daq_recordings_exported` / `daq_recordings_skipped` | Light-sensor recordings, counted separately on purpose — they come from a different stage and exist for runs with no imagery at all, so folding them in would make a DAQ-only run look like it exported images. |

Alongside them: `summary["output_dirs"]` (every directory written to), `summary["light_sensor_export"]`, `summary["stopped"]` (true when the user interrupted the run, so partial counts don't read as a completed run that under-produced), and `summary["groups"]` (the per-group breakdown).

`exported_files` is recorded by the pipeline **as it writes**, not scanned off the project's image objects afterwards. The parallel and GPU strategies build their own image objects (in worker subprocesses for the GPU paths), so the old scan reported `0 file(s) written` for every such run and then emitted the zero-exports hint — on runs where everything had worked. If you script against this number, a healthy parallel run now reports a non-zero count.

Light-sensor skips report the reason the reader actually established for each file — an unreadable schema, a missing bundle, a write error — **deduplicated**, so twenty files skipped for one cause read as one cause rather than twenty repetitions of it.

> **`process()` does not raise when a run produces no images.** This is the one place the SDK and the CLI deliberately differ: `chloros-cli process` treats "products were requested, none were written" as a failure and exits non-zero, whereas the SDK returns normally and reports the condition through `summary` / hints. If your pipeline should stop on an empty run, check it yourself — inspect `summary` (or count the files under the project folder) rather than relying on the absence of an exception. The usual causes are an input folder that wasn't recognised as a capture and products skipped as inapplicable for the cameras present (e.g. radiance from RGB-only cameras).

### Convenience Functions

```python
# One-call process: project + import + configure + process
results = chloros_sdk.process_folder(
    folder_path="C:/DroneImages/Flight001",
    project_name="FieldA_2026-05-26",
    camera="Survey3N_RGN",
    indices=["NDVI", "NDRE", "GNDVI"],
    vignette_correction=True,
    reflectance_calibration=True,
    export_format="TIFF (16-bit)",
    mode="parallel",
    debayer="High Quality (Faster)",      # or "Texture Aware (Slow, Highest Quality)"
    ppk=False,
    recursive=False,
    processing_timeout=14400,
)

# LATTICE-friendly defaults (no panel-target detection, standard debayer)
results = chloros_sdk.process_lattice_capture(
    folder_path="C:/Captures/2026-05-13_Field",
    indices=["NDVI"],
)

# Audit which calibration sources were applied to a processed image
tags = chloros_sdk.read_image_audit_tags("output/Reflectance_Calibrated/x.tif")
print(tags["CalibrationSource"])   # 'per_serial' / 'legacy_lookup' / 'none'
print(tags["VignetteSource"])      # 'per_serial' / 'legacy_polynomial' / 'none'
```

### Supported Values

```python
# export_format
"TIFF (16-bit)"           # default, recommended
"TIFF (32-bit, Percent)"  # reflectance percentage as float32
"PNG (8-bit)"
"JPG (8-bit)"

# debayer
"High Quality (Faster)"               # standard, default
"Texture Aware (Slow, Highest Quality)"  # neural debayer, Chloros+ only
"Standard (Fast, Medium Quality)"      # alias used internally for LATTICE

# input_level (LATTICE only; Survey3 .raw ignores)
"auto"        # default — infers from each file's XMP ProcessingLevel tag
"raw"         # force-treat as raw Bayer
"debayered"   # force-treat as already-debayered BGR
"processed"   # force-treat as already-calibrated radiance

# array_alignment / array_alignment_crop (LATTICE arrays; None = keep saved setting)
True          # backend default — apply the module-to-module transform stamped
              # in each capture's Chloros:Alignment* XMP to every product
False         # export in native sensor geometry / skip the common-overlap crop

# array_alignment_interpolation (alignment warp resampling)
"bilinear"    # backend default
"nearest"     # preserves exact source DNs (no inter-pixel value mixing)
"cubic"
```

#### Radiometric Output (LATTICE multispectral pipeline)

The `process` pipeline's LATTICE multispectral (M3C/M3M) export level — `reflectance` (default), `radiance`, `sensor-response`, or `all` (every applicable mode per image) — maps to the project's **"Radiometric output"** processing setting. `configure()` has a dedicated keyword for it:

```python
with chloros_sdk.ChlorosLocal() as cl:
    cl.create_project("Field_A")
    cl.import_images("C:/Captures/lattice_flight")
    cl.configure(
        radiometric_output="radiance",   # reflectance (default) / radiance / sensor-response / all
        export_format="TIFF (32-bit, Percent)",
    )
    cl.process()
```

The advanced escape hatch — writing the project's `"Radiometric output"` key through `custom_settings` — still works, but remember it replaces the whole settings block (see the warning below):

```python
cl.configure(custom_settings={
    "Project Settings": {
        "Processing": {"Radiometric output": "radiance"},
        "Export": {"Calibrated image format": "TIFF (32-bit, Percent)"},
    }
})
```

`reflectance` (the default) divides camera radiance by the **timestamp-matched DAQ downwelling**, resolved automatically from a recorded `.daq` (DAQ-U/M/E) **or a DAQ-M native `.csv`** found alongside the imagery; any per-camera or DAQ calibration bundle missing locally is **auto-fetched from AWS** on first use. The CLI exposes this as per-type product toggles on `chloros-cli process`: `--radiance`/`--no-radiance`, `--reflectance`/`--no-reflectance`, `--debayered`, `--preview`.

> `custom_settings` **replaces** the entire computed settings block (it bypasses `configure()`'s other keywords and validation by design). When you use it, include every `Project Settings` key you care about, as in the example above.

***

## Smart-Connect for LATTICE Cameras

Persistent backend sessions for live hardware. Same endpoints the GUI uses, so behaviour is identical across SDK / CLI / GUI.

### Single Camera — `CameraSession`

```python
import chloros_sdk

# Open by serial; reuses existing pool entry if one exists
with chloros_sdk.connect_camera("213800234") as cam:
    # cam is a CameraSession; supports context manager + manual disconnect
    cam.set_settings(
        exposure_time=10000,    # microseconds
        gain=0.0,               # dB
        pixel_format="BayerRG12",
        target_brightness=80,
        ae_damping=8.0,
    )
    cam.capture("output/", ext=".tiff")
```

#### `connect_camera()` Signature

```python
connect_camera(
    serial,
    *,
    preset=None,                       # "default" | "high_quality" | "high_speed" | "triggered"
    settings=None,                     # dict overlaid on the preset
    backend_url="http://127.0.0.1:5000",  # deliberately not 'localhost' (::1-first on Windows ≈ 2 s/request)
    timeout=60.0,
    auto_start_backend=True,           # spawn a local backend if none is running
) -> CameraSession
```

#### `CameraSession` Methods

| Method                                                                                                                                  | Description                                                                                                                                                                          |
| --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `read_nodes(names, enum_names=(), timeout=30.0)`                                                                                        | Read GenICam nodes; returns `{nodes, errors, enums, device}`.                                                                                                                        |
| `set_settings(**kwargs)`                                                                                                                | Write nodes by friendly name (`exposure_time`, `gain`, `pixel_format`, `width`, `height`, `target_brightness`, `ae_damping`, `ae_upper_limit`, `trigger_mode`, `trigger_source`, …). |
| `capture(output_dir="output", ext=".tiff", jpeg_quality=95, processing=None, levels=None, force_daq=None, settings=None, timeout=None)` | Capture a **single** frame. Returns a one-element list of frame metadata dicts. (Burst/multi-frame capture was removed — call `capture()` in a loop if you need a series.)           |
| `disconnect()`                                                                                                                          | Release from the pool. No-op if we attached to an already-open session.                                                                                                              |

`capture()` export controls (same model as the array + GUI):

* `processing` / `levels` — `processing="all"` saves every applicable export type; `levels=["raw","radiance"]` saves just those (overrides `processing`). Omit both for the backend default.
* `force_daq=True` — save the assigned DAQ/DLS reading as a `.daq` sidecar even on a raw-only grab, so the frame can be reprocessed into reflectance/index later. No-op if no DAQ is linked.

### Synchronized Array — `ArraySession` (Smart-Prep)

`connect_array` is **the recommended entry point** for multi-camera setups. It runs the full GUI smart-prep flow under the hood:

1. **Network analysis** (`/api/camera/array/recommend`) — finds the largest frame size that fits sim-emit tier without dropping frames.
2. **Tier auto-pick** — `sim-capture-sim-emit` if the wire can handle it; otherwise `sim-capture-ftd-stagger` or `slip-emit-and-capture`.
3. **Auto-shrink** — silently shrinks frame size / increases binning when the wire can't sustain the requested resolution. **This safety net does not cover aggregate over-subscription**: too many cams for the wire cannot be fixed by shrinking frames — see [Over-Subscription](#over-subscription-the-per-cam-floor).
4. **PTP enabled** by default — cross-camera timestamps land on one shared clock to **\~1 ms**. Simultaneous exposure comes from the M8 hardware trigger (**< 100 µs** inter-module), not from PTP: PTP aligns *timestamps*, not exposures.
5. **Per-cam pixel-format auto-pick** — RGB cams → `BayerRG8`, multispec → `BayerRG12`.
6. **AE seeding** — snapshots each cam's current AE state so connect doesn't reset exposure mid-flight.
7. **GPIO trigger config** — `connect_array` arms every camera (`TriggerMode=On`, `TriggerSource=Line2`) so the master's pulse drives the slaves over the M8 cable. This is an array-only step: a single camera opened with `LatticeCamera` free-runs instead.

```python
import chloros_sdk

# First serial is the MASTER (fires the trigger pulse); rest are slaves.
with chloros_sdk.connect_array(
        ["213800234", "214000533", "214701288", "214701292"]) as arr:
    print(arr.array_id, arr.sync_mode, arr.ptp_enabled)
    arr.capture("output/", processing="reflectance")
```

#### `connect_array()` Signature

```python
connect_array(
    serials,                              # list[str]; serials[0] = master
    *,
    line="Line2",                         # GPIO sync line: Line0 | Line2 | Line3
    target_fps=None,                      # master trigger fire rate (auto if None)
    force_tier=None,                      # override tier picker; see below
    wire_ceiling_mbps=None,               # host sustained wire budget, MB/s (auto if None)
    width=None,                           # explicit frame size; skips network analysis
    height=None,
    pixel_format=None,
    binning=None,
    recommend=True,                       # set False to skip the recommend step
    ptp_enable=True,                      # set False to disable PTP
    backend_url="http://127.0.0.1:5000",  # same IPv6-avoidance default as connect_camera
    timeout=180.0,
    auto_start_backend=True,              # spawn a local backend if none is running
) -> ArraySession
```

`force_tier` values:

* `"sim-capture-sim-emit"` — true simultaneous (all cams fire on the same clock edge).
* `"sim-capture-ftd-stagger"` — flexible time-domain stagger (cams emit at slightly offset times so packets serialize on the wire).
* `"slip-emit-and-capture"` — sequential per-cam capture (no temporal sync; only option when no frame size fits sim).

`wire_ceiling_mbps` overrides the **host's sustained wire budget** in MB/s — the single number the whole array allocation hangs off. Leave it `None` to use the auto-detected value. Lower it when the array reports GVSP-corrupt frames: the auto value is derived from the NIC's advertised link rate, which over-states USB adapters, thin PCIe lanes and busy shared fabrics — and the over-estimate surfaces as corrupt frames rather than as a visibly slow link. The value is persisted in the project's array capture block, so a reopen or a later `connect_array` restores it like any other array setting. See [Array Health](#array-health--which-subsystem-is-losing-frames).

#### Over-Subscription (the per-cam floor)

Sim-emit pacing allocates each camera a share of the collision-safe wire budget, floored at **8 MB/s per camera** (`per_cam_floor_bps`). Once `N × floor` exceeds the collision-safe ceiling, the array **over-subscribes the wire** — the failure mode is GVSP packet loss, not a lower frame rate — and no frame-size remedy exists: **binning and ROI lower bytes per frame, not the paced bytes per second** the aggregate check compares. Practical full-res ceilings on a 1 GbE host: **6 cams @ 1500 MTU, 9 with jumbo frames** (`max_cams_collision_safe` in the analysis response reports the ceiling for your wire). Remedies: fewer cams, jumbo frames end-to-end, or a faster NIC.

* The `analyze_array_network()` and `/api/camera/array/connect` responses carry `oversubscribed`, `aggregate_demand_bps`, `collision_safe_ceiling_bps`, `max_cams_collision_safe`, and `per_cam_floor_bps`. When `oversubscribed` is true, the projection **zeroes the fps fields** (`achievable_fps_max` / `fps_bright` / `fps_dark`) rather than reporting a misleading slow-but-working rate.
* `POST /api/camera/array/connect` accepts a `pin_resolution` body param (**HTTP-only — not an SDK kwarg**; `connect_array` doesn't expose it). Pinning removes the binning walk-down safety net, so an over-subscribed connect with `pin_resolution` set is **hard-refused** with an error naming every remedy. Without pinning, connect proceeds with the walk-down but warns that shrinking cannot clear the aggregate.
* Bench-work escape hatch: set `CHLOROS_ARRAY_ALLOW_OVERSUBSCRIBED=1` in the backend's environment to downgrade the refusal to a loud warning — you connect anyway and accept the packet loss.

#### Array Health — which subsystem is losing frames

`GET /api/camera/array/<array_id>/capability` carries a live `health` block on a connected array, re-evaluated on a rolling **10-second** window. It splits frame loss into the two causes that need opposite fixes, instead of one "incomplete" rate that names neither:

| Field                                                | What it means                                                                 | Which subsystem                                      |
| ---------------------------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------- |
| `gvsp_corrupt_rate_pct` (per serial)                 | The frame **arrived and was structurally bad** — GVSP packet loss.            | **Network**: wire budget, pacing, NIC RX ring, MTU   |
| `never_arrived_rate_pct` (per serial)                | The frame **never came at all** — the camera didn't fire, or nothing left it. | **Trigger / sync**: M8 cable, `line=`, `TriggerMode` |
| `worst_gvsp_corrupt_pct` / `worst_never_arrived_pct` | Worst camera's rate for each.                                                 | —                                                    |
| `per_cam_rate_pct`                                   | Combined incomplete rate per camera (both causes together).                   | —                                                    |
| `stable_for_seconds`                                 | How long every camera has stayed under 0.01 %.                                | —                                                    |

Alongside `health`, the same record reports the number the whole allocation hangs off:

| Field                      | What it means                                                                                                                             |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `wire_ceiling_mbps`        | The host's sustained wire budget in force, MB/s.                                                                                          |
| `wire_ceiling_source`      | Where that number came from, in words — e.g. `USB-capped 200 MB/s (was theoretical 1062; …)` or `user override 120 MB/s (auto said 200)`. |
| `wire_ceiling_is_user_set` | `true` when `wire_ceiling_mbps=` set it.                                                                                                  |
| `nic_is_usb`               | `true` for a USB Ethernet adapter.                                                                                                        |

There is no SDK wrapper for this endpoint — read it directly:

```python
import requests, chloros_sdk

arr = chloros_sdk.attach_array(["213800234", "214000533"])
h = requests.get(
    f"http://127.0.0.1:5000/api/camera/array/{arr.array_id}/capability",
    timeout=10).json()

health = h.get("health", {})
print("wire ceiling:", h["wire_ceiling_mbps"], "MB/s", h["wire_ceiling_source"])
print("corrupt (network) :", health.get("worst_gvsp_corrupt_pct"), "%")
print("absent  (trigger) :", health.get("worst_never_arrived_pct"), "%")

if (health.get("worst_gvsp_corrupt_pct") or 0) > 1.0:
    # Network path. Reconnect with a lower budget -- NOT a lower target_fps.
    arr.disconnect()
    arr = chloros_sdk.connect_array(serials, wire_ceiling_mbps=120)
```

**Reading it:** non-zero `gvsp_corrupt_rate_pct` with `never_arrived_rate_pct` at 0 means triggering and cable sync are perfect and 100 % of the loss is on the network path — lower `wire_ceiling_mbps` and reconnect. The reverse pattern points at the sync cable or the trigger line instead.

> **`target_fps` is not the lever for corrupt frames.** GevSCPD pacing is written once at connect, so lowering the trigger rate changes the duty cycle and not the simultaneous-emit burst rate. A measured 5× demand cut produced no improvement, while dropping the wire ceiling from 240 to 200 MB/s took the same rig from 10.4 % corrupt to 0.00 %.

> **Mid-stream auto-shrink is unavailable on TRI032S firmware.** A running array cannot fix this itself; disconnect and reconnect so the connect-time picker re-plans against the new ceiling.

A **USB Ethernet adapter is capped at 200 MB/s** by the probe regardless of its nameplate: the efficiency table that turns a link rate into a sustained figure is PCIe-derived, and a USB NIC advertises its Ethernet link rate while being bounded by the USB bus and its driver. The cap is an absolute, not a fraction — a USB 1 GbE adapter derives \~80 MB/s and is unaffected.

#### `ArraySession` Methods

| Method                                                                                                                                                          | Description                                                                                                                                                           |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `status(timeout=10.0)`                                                                                                                                          | Live `{fps, ptp, frame_count, last_error, …}`.                                                                                                                        |
| `capture(output_dir="output", format="tiff", processing="debayered", levels=None, aligned=None, render_index=None, force_daq=None, smart=False, timeout=300.0)` | One synced capture group. Returns a `CaptureResult` (list of frame dicts + `.skipped`). Export controls below.                                                        |
| `capture(..., smart=True)`                                                                                                                                      | **Smart capture** — waits for AE to settle across all cams, then triggers.                                                                                            |
| `capture_fastest(output_dir="output", force_daq=True, render_index=True, timeout=120.0)`                                                                        | Fastest capture: raw-only + the assigned DAQ reading (+ the free combined index). Mirrors the GUI "Fastest Capture" button.                                           |
| `capture_repeated(output_dir="output", count=None, duration_s=None, interval_s=0.0, on_capture=None, **capture_kwargs)`                                         | Single / Continuous / Interval in one bounded loop. Returns `list[CaptureResult]`. **Requires `count` and/or `duration_s`** so it terminates (the SDK has no Ctrl+C). |
| `record(output_dir="output", fps=10.0, duration_s=None, video=True, gif=False, timeout=30.0)`                                                                   | Start recording the live combined-index view to video/GIF → `RecorderHandle`. One composite recorder per array.                                                       |
| `burst(output_dir="output", duration_s=None, max_frames=None, index_config=None, serial_index_config=None, timeout=30.0)`                                       | Start a high-fps raw-Bayer burst → `RecorderHandle`. Reprocess offline with `build_video()`.                                                                          |
| `build_video(burst_dir, products=None, fps=10.0, video=True, gif=False, save_tiffs=False, wait=True, poll_s=2.0, timeout=1800.0)`                               | Offline-reprocess a saved raw burst into calibrated video(s). Blocks until done (`wait=True`) and returns `{outputs, errors, combined}`.                              |
| `build_video_status(job_id, timeout=15.0)`                                                                                                                      | Poll an offline build job: `{running, result, error, burst_dir}`.                                                                                                     |
| `disconnect()`                                                                                                                                                  | Release the whole array.                                                                                                                                              |

`capture()` export controls (same endpoint the GUI/CLI use):

* `processing` / `levels` — `processing="all"` (or `levels=["raw","radiance",…]`) saves every applicable export type per cam; a single `processing` value saves just that level.
* `aligned=True` — warp every member's non-raw export to the array's [alignment profile](#array-alignment) (co-registered); raw stays unwarped but carries the transform in metadata. Falls back to unaligned (with a warning surfaced in the result's `alignment`) if the array has no profile.
* `render_index=False` — skip the per-cam vegetation-index overlay; default renders it where configured.
* `force_daq=True` — save the assigned DAQ/DLS reading as a `.daq` sidecar even when no chosen level needs it.

**TIFF compression (HTTP-only knob):** `ArraySession.capture()` sends no `compression` key, so the backend default applies — `POST /api/camera/array/capture` reads a `compression` body param, `"deflate"` by default (lossless zlib L1 + horizontal predictor, \~4.1 MB per full-res frame). `"none"` writes uncompressed (\~6.3 MB/frame) with a **\~5× faster write** — both are lossless and read identically on import. The SDK exposes no kwarg for it; the escape hatch is `chloros-cli lattice array-capture --compression none` or raw HTTP. DEFLATE also holds the Python GIL, so compressed writes don't parallelize across the per-cam writer threads — sustained 8-cam full-res capture at sensor rate needs `compression: "none"`. Details: [CLI Reference → array-capture](/chloros/reference-cli-and-sdk/cli-reference.md).

**Per-member export overrides (HTTP-only):** the same endpoint also accepts `exclude_serials` (list — drop members from the saved set; the array still triggers as one synced group and excluded members are returned in `excluded`), `serial_levels` (`{serial: [level tokens]}` per-cam level overrides), and `serial_index` (`{serial: bool}` per-cam index-overlay overrides). These are GUI-parity body params and **not SDK kwargs yet**; members absent from the maps fall back to the array-wide `levels` / `render_index`.

**Inspecting Skipped Cams — `CaptureResult.skipped`**

`ArraySession.capture()` returns a `CaptureResult`, which is a `list` subclass: iterate it, index it, `len()` it — every existing pattern keeps working. New code can inspect the `.skipped` attribute to see which cams were excluded and why. The most common case is RGB cams in a mixed-filter array when you ask for `processing="radiance"` or `"reflectance"` — per-Bayer radiance is meaningless for a broadband sensor, so the backend skips those cams rather than producing nonsense.

```python
with chloros_sdk.connect_array(serials) as arr:
    result = arr.capture("output/", processing="reflectance")

    # Back-compat: iterate as a plain list
    for frame in result:
        print(frame["filepath"], frame["serial"])

    # New: see why N-1 cams were saved
    for skip in result.skipped:
        print(f"skipped SN:{skip['serial']} reason={skip['reason']}")
        # e.g. {'serial': '214701292', 'level': 'reflectance',
        #       'reason': 'reflectance-not-applicable-to-rgb-cam',
        #       'filter': 'RGB'}
```

Reason tokens follow the pattern `<level>-not-applicable-to-rgb-cam` (one entry per skipped level, each carrying `level`). The reflectance-specific skips are `reflectance-skipped-no-fresh-dls` (no fresh downwelling reading available), `reflectance-skipped-bound-daq-unavailable (…)` (the bound DAQ could not be reached), and `dls-uncalibrated-band-<nm>` — the band lies mostly outside the DAQ light sensor's radiometrically calibrated range (\~374–974 nm), so the absolute DAQ-based reflectance divide is refused and the frame demotes loudly to sensor-response. Among shipping SKUs only F988 triggers it; that camera's supported path is the reflectance-panel workflow.

`processing` levels:

| Level                         | Output                                                                                                                                                                                                                                                                                                                        |
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `"raw"`                       | Single-channel Bayer (mono cams: the single band) straight from the sensor.                                                                                                                                                                                                                                                   |
| `"debayered"` *(SDK default)* | 3-channel BGR via bilinear demosaic (mono cams: 1-channel grayscale).                                                                                                                                                                                                                                                         |
| `"radiance"`                  | float32 W/m²/sr/nm via the full radiometric chain. Multispectral only — RGB cams are skipped.                                                                                                                                                                                                                                 |
| `"reflectance"`               | uint16 0..32768 (Pix4D-ready); requires a live DAQ pairing for absolute reference. Multispectral only.                                                                                                                                                                                                                        |
| `"display"`                   | Full chain matching the GUI preview (CCM + WB + gamma per the cam's profile).                                                                                                                                                                                                                                                 |
| `"all"`                       | **One file per applicable level** for each cam (matching the GUI "Capture All" / CLI default). The returned `CaptureResult` then holds one frame dict per `(cam, level)`, with the level in each dict; inapplicable levels appear in `.skipped`. The DAQ reading used for any reflectance frame is saved as a `.daq` sidecar. |

> **Note — default differs from the CLI.** `ArraySession.capture()` defaults to `processing="debayered"`; the `chloros-cli lattice array-capture` command defaults to `processing="all"`. Pass `processing="all"` explicitly from the SDK to mirror the CLI/GUI multi-level save.

### Capture Modes & Recorders

The array surface mirrors the GUI capture panel: Single / Continuous / Interval / Fastest shutter modes, plus two recorders (live-composite video and raw burst → offline reprocess).

```python
import time, chloros_sdk

with chloros_sdk.connect_array(serials) as arr:
    # Single (default) — one synced group
    arr.capture("out/", processing="reflectance")

    # Fastest — raw + .daq + combined index now, calibrate later
    arr.capture_fastest("flightline/")

    # Interval — one reflectance pass every 2 s, 5 passes (bounded so it ends)
    arr.capture_repeated("timelapse/", count=5, interval_s=2.0,
                         processing="reflectance",
                         on_capture=lambda i, r: print(f"pass {i}: {len(r)} frames"))

    # Combined-index video/GIF recorder (needs the combined live view streaming)
    with arr.record("monitoring/", fps=10, gif=True) as rec:
        time.sleep(30)
    print(rec.result["video_path"])

    # Raw-Bayer burst → offline reprocess into calibrated video(s)
    with arr.burst("capture/", duration_s=5) as b:
        pass
    out = arr.build_video(b.result["out_dir"], products=[
        {"kind": "per_cam", "level": "reflectance"},
        {"kind": "combined", "level": "index"}])
    print(out["outputs"])
```

* **`capture_repeated`** is the SDK's Continuous/Interval loop. Because there's no `Ctrl+C` to break it from a script, you **must** pass `count` and/or `duration_s` (it stops when either is reached). `interval_s` is measured from the start of each pass (matching the GUI). Remaining kwargs pass straight through to `capture()`.
* **`record`** is *monitoring-grade*: it captures the live combined-index composite as-displayed, so the combined stream must be open for frames to land. One composite recorder per array (raises if one is already running).
* **`burst` → `build_video`** is *analysis-grade*: `burst` writes raw frames + a per-frame manifest + one `.daq` per distinct DLS reading under `<output>/bursts/<base>/` at the grab loop's full rate (no chain, no exiftool, no live view). `build_video` time-matches each frame to the nearest `.daq` and re-runs the import pipeline's radiance/reflectance/index chain. `products` is a list of `{"kind": "per_cam"|"combined", "level": "radiance"|"reflectance"|"index"}` (default: the combined index). `burst().stop()` also auto-kicks a best-effort combined-index build, returned as `build_job` in the stop result.

#### `RecorderHandle`

Returned by `ArraySession.record()` and `ArraySession.burst()`. Use it as a context manager to auto-stop on scope exit, or drive it manually.

| Member                | Description                                                                                                 |
| --------------------- | ----------------------------------------------------------------------------------------------------------- |
| `job_id`              | Backend job id (str).                                                                                       |
| `kind`                | `"composite"` (from `record`) or `"raw"` (from `burst`).                                                    |
| `start_stats`         | The dict returned by the `start` call.                                                                      |
| `result`              | `None` while running; the final stop-result dict once stopped.                                              |
| `stats(timeout=10.0)` | Live job stats (frames written, realized fps, elapsed).                                                     |
| `stop(timeout=60.0)`  | Stop the recorder; returns + caches the final result. Idempotent (a second call returns the cached result). |

```python
rec = arr.burst("capture/")
# ... drive manually ...
print(rec.stats()["frames"])
result = rec.stop()
print(result["out_dir"], result.get("build_job"))
```

### Attaching to an Already-Connected Array — `attach_array`

If the array is already up (the GUI opened it, or a previous SDK session called `connect_array`), use `attach_array` to grab a handle to it instead of re-connecting. `connect_array` always errors with "Camera is already in array " in that situation, because POSTing `/array/connect` for a member-in-pool is not idempotent; `attach_array` reads `/api/camera/array/list` and matches by either array\_id or serials.

```python
import chloros_sdk

# By serials (matches if every serial is a member of one existing array)
arr = chloros_sdk.attach_array(
    ["213800234", "214000533", "214701288", "214701292"])

# By array_id (when you've already noted it down)
arr = chloros_sdk.attach_array("array-1779862544497")

# attach_array returns the same ArraySession as connect_array
arr.capture("output/", processing="reflectance")
```

Pattern: SDK scripts that co-tenant with the desktop GUI should try `attach_array` first and fall back to `connect_array` if no array is in the pool yet.

```python
import chloros_sdk

try:
    arr = chloros_sdk.attach_array(serials)
except chloros_sdk.ChlorosConnectError:
    arr = chloros_sdk.connect_array(serials)
```

> **Important — context-manager exit DOES disconnect.** `ArraySession.disconnect()` always POSTs `/array/disconnect`; there is no attached-not-owned guard like there is for `CameraSession` / `DAQSensorSession`. If you're co-tenanting with the GUI and don't want to tear the array down on scope exit, **do not use the `with` block** — keep the handle in a normal variable and skip the explicit `disconnect()`:
>
> ```python
> arr = chloros_sdk.attach_array(serials)
> arr.capture("output/", processing="reflectance")
> # … script ends; array stays up for the GUI
> ```

### Network-Analysis Helper

Useful before opening the array — projects whether your proposed settings will fit:

```python
result = chloros_sdk.analyze_array_network(
    master_serial="214701288",
    slave_serials=["213800234", "214000533", "214701162"],
    width=2048, height=1536,
    pixel_format="BayerRG12",
    binning=1,
)

if result["status"] == "ok":
    print("Use the requested settings.")
elif result["status"] == "auto_capped_fps":
    r = result["recommended"]
    print(f"Keep the resolution; cap the trigger rate at {r['recommended_target_fps']} fps")
elif result["status"] == "auto_shrunk":
    r = result["recommended"]
    print(f"Shrink to {r['out_width']}x{r['out_height']} binning={r['binning']}")
elif result["status"] == "needs_force_slip":
    print("Sim-sync impossible on this wire; force_tier='slip-emit-and-capture' required")
```

`status` is one of `ok` / `auto_capped_fps` / `auto_shrunk` / `needs_force_slip` (else `error`). `auto_capped_fps` means the requested resolution fits the RX ring only at a capped trigger rate — keep the resolution and pass `target_fps=result["recommended"]["recommended_target_fps"]` to `connect_array` (see [Example 6](#6-capability-probe-before-connecting-a-4-cam-array)).

**How to read the projection** (same model as the GUI Array Settings panel):

* **Burst (`frame_bytes_total`) is summed per-camera at each cam's real pixel format.** Mono **M3M** cams stream Mono12 (2 B/px) regardless of the `pixel_format` you pass, so a 4-cam full-res frame is **\~25 MB** with three mono cams, not the \~12.6 MB an all-8-bit assumption gives. The backend resolves each cam's format from its model.
* **Admittance (`burst_fits_nic_ring`) is drain-aware**, not whole-burst-vs-ring: sim-emit fits when the host drains the RX ring faster than the cams fill it. A 10G host + 1 GbE cams **admits** full-res even when the burst exceeds the ring; a 1 GbE host blocks (`needs_force_slip` / `auto_shrunk`).
* **`achievable_fps_max` is a conservative serial-retrieve ceiling** — `max(readout+emit, N×emit)` with per-cam emit clamped to the 1 GbE camera link, exposure-independent. E.g. \~2.8 fps for a 4-cam full-res 12-bit array (matches the runtime's measured \~2.7–3.0). Full model: [CLI Reference → Array fps & burst model](/chloros/reference-cli-and-sdk/cli-reference.md#array-fps--burst-model).
* **Over-subscription (`oversubscribed: true`) means N × per-cam floor exceeds the collision-safe ceiling** — the fps fields (`achievable_fps_max` / `fps_bright` / `fps_dark`) read 0, and auto-shrink/binning cannot fix it (they lower bytes per frame, not paced bytes per second). Remedies are fewer cams, jumbo frames, or a faster NIC; `max_cams_collision_safe` reports the ceiling (6 full-res cams on 1 GbE @ 1500 MTU, 9 with jumbo). The response also carries `aggregate_demand_bps`, `collision_safe_ceiling_bps`, and `per_cam_floor_bps` (8 MB/s). See [Over-Subscription](#over-subscription-the-per-cam-floor).

### Discovery & Listing

```python
chloros_sdk.discover_lattice_cameras()   # list all cams visible to the backend
chloros_sdk.list_cameras()               # cams currently in the pool
chloros_sdk.list_arrays()                # active arrays in the pool
```

***

## Smart-AE / Smart-Capture

LATTICE arrays run continuous AE in the background as soon as they're connected, but a freshly-pointed scene takes a moment to converge. **Smart-capture** is the packaged convenience: it polls each cam's exposure, waits until the array is stable across a window, then triggers the capture. It is GUI-equivalent: the desktop app's "smart" capture button calls the same backend endpoint.

```python
import chloros_sdk

with chloros_sdk.connect_array([
        "213800234", "214000533", "214701288", "214701292"]) as arr:
    # Initial pose
    arr.capture("pose_a/", processing="reflectance", smart=True)
    input("Move the rig, then press Enter...")
    # New pose — smart-capture waits for AE to re-settle automatically
    arr.capture("pose_b/", processing="reflectance", smart=True)
```

When driving via `ChlorosProject` (next section) you get more knobs:

```python
proj.arrays["main_rig"].capture_smart(
    output_dir="out/",
    processing="reflectance",
    settle_timeout_s=5.0,           # max wait
    stability_window_s=1.5,         # exposure must hold steady this long
    exposure_tolerance_pct=5.0,     # %-spread allowed within the window
)
```

The smart-AE policy is conservative by default. Tighten `exposure_tolerance_pct` for picky radiometric work; widen for fast-changing scenes where you just want "close enough."

***

## DAQ Sensor Sessions

Persistent backend pool for spectral sensors (DAQ-U over USB, DAQ-M over BLE, DAQ-E over Ethernet). Mirrors the camera surface: smart-detect, pool reuse, idempotent attach.

### Smart-Detect (Zero-Config)

```python
import chloros_sdk

with chloros_sdk.connect_daq_sensor() as daq:
    print(daq.model, daq.transport, daq.address)
    for frame in daq.latest(n=10):
        spectrum = frame["spectrum"]   # list[float] (W/m²/nm if calibrated)
        is_sat = frame["is_saturated"]
        x, y, z = frame["x"], frame["y"], frame["z"]
        print(len(spectrum), is_sat)
```

Precedence: Ethernet → BLE → USB. Pass any one explicit hint to pin the transport.

### Pinned Transport

```python
# DAQ-U on a specific serial port
daq = chloros_sdk.connect_daq_sensor(transport="usb", port="COM3")

# DAQ-M over BLE by MAC (implies transport="ble")
daq = chloros_sdk.connect_daq_sensor(mac="AA:BB:CC:DD:EE:FF")

# DAQ-E over Ethernet by hostname (implies transport="eth")
daq = chloros_sdk.connect_daq_sensor(eth_host="daq-e-xxx.local")

# Tuning knobs
daq = chloros_sdk.connect_daq_sensor(
    port="COM3",
    integration_time=64,      # ms
    frame_avg=20,
    enable_ae=True,
    start_streaming=True,
)
```

### `DAQSensorSession` Methods

| Method                                            | Description                                                                                                                 |
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `status(timeout=10.0)`                            | Pool entry summary (streaming/recording state, wavelength range, calibration sha, integration time, frame\_avg, AE state).  |
| `latest(n=1, timeout=10.0)`                       | Return up to N most-recent spectrum frames.                                                                                 |
| `stream_start()` / `stream_stop()`                | Resume / pause streaming (handle stays open).                                                                               |
| `record_start(output_dir=None, device_name=None)` | Start recording a .daq file. Returns the filepath. Refuses for DAQ-U/M without an AWS calibration bundle (DAQ-E is exempt). |
| `record_stop()`                                   | Stop recording. Returns `{path, rows}`.                                                                                     |
| `disconnect()`                                    | Release from the pool. No-op for attached-not-owned handles.                                                                |

> **Cap-correction profiles (`cap_id`) are not an SDK knob.** `connect_daq_sensor()` / `DAQSensorSession` expose no `cap_id` parameter or `set_cap` method. Select a fleet cap-correction profile via the CLI (`chloros-cli daq pool-connect --cap-id …` / `chloros-cli daq pool-set-cap …`) or the backend's `/api/daq` HTTP routes (`/api/daq/connect` and `/api/daq/<id>/cap-id` accept `cap_id`).

### Discovery — finding an address to connect with

`discover_daq_sensors()` scans USB / BLE / ETH for sensors you *could* open. It is the DAQ counterpart to `discover_lattice_cameras()`, and the only way to obtain a **DAQ-M's BLE MAC** — a DAQ-E has a hostname and a DAQ-U a COM port, but a MAC is neither printed on the device nor listed by the OS.

```python
for s in chloros_sdk.discover_daq_sensors():
    print(s["transport"], s["address"], s["model"], s["extra"])
# ble  C3:D8:85:E0:0A:19  DAQ-M  {'name': 'NSP32_SPECTRUM'}
# usb  COM3               None   {'manufacturer': 'Intel'}

# `address` is exactly what connect_daq_sensor wants:
for s in chloros_sdk.discover_daq_sensors(transports=["ble"]):
    if s["model"] == "DAQ-M":
        daq = chloros_sdk.connect_daq_sensor(mac=s["address"])
```

| Field       | Description                                                                                                                                                                           |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `transport` | `usb` \| `ble` \| `eth`.                                                                                                                                                              |
| `address`   | COM port / BLE MAC / hostname — pass to `connect_daq_sensor` as `port=` / `mac=` / `eth_host=`.                                                                                       |
| `display`   | Human-readable label.                                                                                                                                                                 |
| `model`     | `DAQ-U` \| `DAQ-M` \| `DAQ-E`, or `None` for a port the scan can't identify (USB serial adapters are indistinguishable without a probe, so unknowns are surfaced rather than hidden). |
| `extra`     | Per-transport details (BLE advertised name, USB manufacturer, DAQ-E ip/fw/…). Empty values are omitted.                                                                               |

| Parameter            | Default   | Description                                                                                                  |
| -------------------- | --------- | ------------------------------------------------------------------------------------------------------------ |
| `transports`         | all three | Sequence (or csv string) limiting the scan. Worth passing when you know what you want — BLE is the slow leg. |
| `scan_timeout`       | 5         | Per-transport scan window in seconds; the backend clamps to 1–20.                                            |
| `timeout`            | 60.0      | HTTP ceiling for the whole call (as elsewhere in the SDK).                                                   |
| `auto_start_backend` | `True`    | Spawn a local backend if none is running. Never spawns for a remote `backend_url`.                           |

> **Sensors already open in the pool do not appear.** A connected BLE peripheral stops advertising and an open COM port can't be probed, so discovery lists what is *available to connect*. An empty result right after you connected something is expected — use `list_daq_sensors()` for what you already hold. Transports whose scan can't run (no bleak / zeroconf installed) are skipped rather than raising, so a machine without Bluetooth still gets its USB and ETH answers.

### Listing

```python
for s in chloros_sdk.list_daq_sensors():
    print(s["sensor_id"], s["model"], s["transport"], s["wavelength_range"])
```

### Co-Tenancy with GUI / CLI

If the GUI already has a sensor open, calling `connect_daq_sensor(port="COM3")` from Python returns a handle marked `already_connected=True`. The session's `disconnect()` is then a no-op so your SDK script doesn't tear the sensor out from under the GUI on scope exit.

### Direct-Hardware Classes (No Backend)

`daq_sdk` is re-exported by `chloros_sdk` so you can also drive sensors end-to-end in-process without the backend:

> **Availability:** `daq_sdk` ships with the Chloros desktop install, **not** with the PyPI package — `pip install chloros-sdk` gives you `lattice_sdk` but leaves `chloros_sdk.DAQ_AVAILABLE == False`. Check that flag before using these classes; on a pip-only host drive the sensor through [`connect_daq_sensor()`](#daq-sensor-sessions) instead, which needs no local transport libraries.

```python
from chloros_sdk import DAQUSensor, DAQMSensor, DAQESensor, discover_all

# Discovery
for d in discover_all(timeout=3.0):
    print(d.model, d.display, d.address)   # USB serials: d.extra.get("serial_number")

# Direct DAQ-U
sensor = DAQUSensor(port="COM3")
sensor.connect()
sensor.start_streaming()
# ... use sensor.add_spectrum_callback(...) ...
sensor.stop()
```

Prefer the smart-connect path (`connect_daq_sensor`) when you want shared ownership with the GUI; use the direct classes for headless scripts that own the sensor exclusively.

***

## Project Automation — `ChlorosProject`

A saved Chloros project is a folder containing `cameras.json` + `sensors.json` + `project.json`. `open_project` loads the manifest, and `connect_all` brings every saved device online with its saved settings — same hardware state the GUI would produce.

### Minimal Example

```python
import chloros_sdk

proj = chloros_sdk.open_project("/home/user/Chloros Projects/Field_A")
report = proj.connect_all(verbose=True)
print(report)  # {'cameras': {...}, 'arrays': {...}, 'sensors': {...}}

# Cameras and arrays are addressable by name OR serial / array_id
cam = proj.cameras["FrontLeft"]
cam.capture("./out", format="tiff", processing="reflectance")

arr = proj.arrays["main_rig"]
arr.capture("./out", format="tiff", processing="reflectance")

# Read a DAQ
spectrum = proj.sensors["Sky"].read()

# Trigger every device simultaneously
proj.capture_all("./out")

proj.disconnect_all()
```

Or as a context manager:

```python
with chloros_sdk.open_project("/path/to/proj") as proj:
    proj.connect_all()
    proj.arrays["main_rig"].capture("./out", processing="reflectance")
```

### `ChlorosProject` Methods

| Method                                                                            | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `connect_all(cameras=True, arrays=True, sensors=True, verbose=False, align=None)` | Discover + connect every saved device. Returns a per-class connect report. Uses a running backend when one is listening on `127.0.0.1:5000`; otherwise silently falls back to direct (backend-free) `lattice_sdk` device control — it never spawns a backend.                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `disconnect_all()`                                                                | Tear down everything.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `capture_all(output_dir=".")`                                                     | One frame from every cam + array + spectrum from every sensor.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `stream(camera, overlays=False, fps=10.0)`                                        | Generator yielding BGR `numpy` frames from a named cam (or array). `overlays=False` is a direct `lattice_sdk` grab loop (arrays yield `{serial: frame}` dicts). `overlays=True` routes through `ChlorosLocal.camera_stream()` → the backend's `/api/camera/<serial>/stream-annotated` MJPEG feed, with the cam's saved `ui.overlay` block passed through as query params. Requires backend mode and a **standalone camera**: a direct-mode cam raises `RuntimeError` (the backend can't grab a cam this process owns) and an array raises `NotImplementedError` (overlays composite per camera — stream a member by name). One-shot equivalent: `CameraHandle.capture(annotated=True)`. |
| `align_arrays(align=True, verbose=False)`                                         | Run alignment on every currently-connected array.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `process(mode="parallel", wait=True, progress_callback=None, poll_interval=2.0)`  | Run the calibration / index pipeline on the project's images (wraps `ChlorosLocal.process`; these four are the **only** accepted kwargs — `indices=` etc. raise `TypeError`; set indices via `ChlorosLocal.configure()`). Lazily constructs a `ChlorosLocal()`, which auto-starts a backend.                                                                                                                                                                                                                                                                                                                                                                                            |

Attributes:

* `proj.cameras` — `Dict[str, CameraHandle]` keyed by name AND serial.
* `proj.arrays` — `Dict[str, ArrayHandle]` keyed by name AND array\_id.
* `proj.sensors` — `Dict[str, SensorHandle]` keyed by name AND slot\_id.
* `proj.config` — `project.json["config"]` dict.

### `CameraHandle`

```python
cam = proj.cameras["FrontLeft"]

# Save a frame to disk (processing-aware)
filepath = cam.capture(
    output_dir="./out",
    format="tiff",
    processing="radiance",           # see the level table below
    apply_calibration=True,          # DSNU + flat + 3x3 unmix + NIST
    apply_white_balance=True,        # DLS-aware WB
    apply_index=False,
    index_expression=None,
)

# In-memory grab (numpy array)
frame = cam.grab(processing="debayered")
frame, header = cam.grab(processing="radiance", with_metadata=True)

# Frame iterator (generator)
for arr in cam.frame_stream(processing="debayered", fps=5, count=100):
    my_analysis(arr)
```

**Processing levels.** `capture()`, `grab()`, and `frame_stream()` all take the same `processing` token, and the chain is cumulative — each level runs everything above it:

| Level         | Output                                                                              | Notes                                                                                                                                               |
| ------------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `raw`         | 1-channel Bayer, sensor-native                                                      | No demosaic. Overlays are not available at this level.                                                                                              |
| `debayered`   | 3-channel BGR (**default**)                                                         | Bilinear demosaic. The only level that works without backend mode.                                                                                  |
| `radiance`    | float32, W/m²/sr/nm, **one plane per band**                                         | Full radiometric chain: demosaic + 3×3 unmix (multispec) + DSNU + flat-field + NIST scale, with exposure × gain divided out so values are absolute. |
| `reflectance` | uint16, 32768 = 1.0 (or uint8, 255 = 1.0 — see `bit_depth`), **one plane per band** | Radiance divided by downwelling irradiance (ρ = π·L/E). Needs a DLS/DAQ reading — see the note below.                                               |
| `display`     | 8-bit sRGB-ish                                                                      | GUI-equivalent render: CCM + white balance + gamma via the cam's active colour profile.                                                             |

Anything other than `debayered` requires backend mode; a direct-mode camera raises `NotImplementedError`. `reflectance` needs a usable downwelling reading — the frame endpoint pulls the pooled DAQ into the camera's DLS slot automatically, but with no DAQ bound the chain refuses the reflectance exit and honestly stamps the demotion in the returned metadata rather than silently handing back a lesser product.

> **Reflectance DN scale — don't hardcode it.** LATTICE reflectance uses `32768` = ρ 1.0 and stamps XMP `Chloros:PixelScale=32768`; Survey3 reflectance uses `65535` = ρ 1.0 and carries no `Chloros:*` tags. Read the tag and divide by it. It is defined in the uint16 domain, so it stays `32768` for every format that rescales (16-bit TIFF, 8-bit PNG/JPG, 32-bit percent) — normalise the stored dtype back to uint16 first (×257 from 8-bit, ×65535 from float). The one exception: an 8-bit-source capture written as 8-bit TIFF is *clipped*, not rescaled, so no scale describes it — Chloros omits `PixelScale` and the MicaSense tuple entirely in that case. Treat a missing tag on a LATTICE reflectance file as "no valid scale", not as a default.

#### Per-band output and mixed arrays

At the `radiance` and `reflectance` levels a camera returns **one plane per band of that camera** — three for an M3C (its three unmixed bands, BGR channel order), one for an M3M (its single narrowband filter). The mono camera's replicated planes are collapsed, so a **mixed M3C/M3M array yields different array shapes per serial**. Read the `bands` key from the frame metadata to learn what each plane is; do not infer it from the channel count:

```python
for serial, (arr, hdr) in arr_handle.grab(processing="reflectance",
                                          with_metadata=True).items():
    print(serial, arr.shape, hdr["bands"])   # ('…', (1536, 2048, 3), ['nir','green','red'])
                                             # ('…', (1536, 2048, 1), ['F685'])
    rho = arr.astype("float32") / hdr["pixel_scale"]
```

Always check `hdr["processing_level"] == "reflectance"` before using the values. A demotion (no DAQ reading, no cal pack, unknown band layout) is reported in `processing_level` and `radiometric_refused` rather than raised — the array you get back is still a valid frame, just not the product you asked for.

#### One trigger per read — `group_id`

**Array members are served from the array's synced frame group, not from each camera's own grab buffer.** The cameras of a hardware-synced array expose together, but N per-camera reads do not *sample* together — reading each camera's latest buffered frame straddles trigger boundaries, and a single dropped frame desynchronises the set with nothing noticing. Serving from the group removes that class of error entirely:

* Every frame carries `group_id`, the array's monotonic publish counter. **Frames from different cameras with the same `group_id` came from one trigger** — a server-side guarantee, not something you reconstruct from timestamps.
* A camera missing from the current group is **omitted rather than back-filled** from a different trigger, so a short dict means "not all cameras made this group".
* A standalone camera has no group and reports `group_id: None`.

```python
frames = arr_handle.grab(processing="reflectance", with_metadata=True)
ids = {hdr["group_id"] for _a, hdr in frames.values()}
assert len(ids) == 1, f"straddled triggers: {ids}"     # belt and braces
```

The same counter is published on the MJPEG stream as `X-Chloros-Group-Id`, so several per-camera streams are reassembled into one trigger by matching that header.

#### Exporting a single band — `band`

`grab()` / `frame_stream()` take `band="<name>"` to export just one of a camera's bands as a single-channel `(H, W, 1)` array — the same shape a mono camera produces, so one-band output is handled identically whether it came from an M3M or from splitting an M3C. An FRGN's red band:

```python
red = cam.grab(processing="reflectance", band="red")        # (1536, 2048, 1)
```

Accepted spellings are the full layout name (`Red_660`, `NIR_850`, `Green_550`) or its short head (`red`, `nir`, `green`), case-insensitive. An unknown name is rejected with the available names listed.

> **Selection is by name, not by channel number — deliberately.** "Channel 1" is `Green_550` in the array's BGR channel order but *red* in the filter code's letter order (`RGN`), and the two disagree. Picking the wrong one yields a perfectly plausible frame of the wrong band, which nothing downstream can detect. There is no numeric index for that reason.

#### Aligning the streams — `align`

`align=True` warps the frame into its array's common reference frame. Each camera is warped through the **same** profile resolution and crop ROI, so per-camera streams opened independently come out **mutually registered** — you do not have to request them together:

```python
for serial in arr_handle.serials:                    # one stream per camera
    frames[serial] = cam(serial).grab(processing="reflectance", align=True)
```

Requirements and behaviour:

* The camera must be in a **hardware-synced** array (M8 sync cable) that has an alignment profile — co-registration assumes the members exposed simultaneously, which a software or free-run array does not provide. Refused with `409` otherwise.
* **Refused, never silently unaligned.** The capture path may save unaligned with a warning because a file keeps its provenance tags and can be re-registered later; a live stream has no such second chance, so a missing profile is an error rather than a fallback.
* Give every stream of one array the **same `max_h`**, or their crops land on different pixel grids.
* Alignment is applied *before* the bit-depth encode, so the warp interpolates full-precision values.

#### `bit_depth` — trading precision for bandwidth

`grab()` and `frame_stream()` accept `bit_depth=16` (default) or `bit_depth=8`:

| `bit_depth` | Reflectance encoding                     | Cost                                                                                                                                                                         |
| ----------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `16`        | uint16, 32768 = ρ 1.0, headroom to ρ 2.0 | Full precision.                                                                                                                                                              |
| `8`         | uint8, 255 = ρ 1.0                       | Half the bytes; \~0.004 ρ quantisation and a **hard clip at ρ 1.0** (specular highlights saturate). `reflectance_clipped_at_1_0` in the metadata reports when that happened. |

**`bit_depth=8` is reflectance-only.** Requesting it for `radiance` is rejected rather than served: reflectance has a fixed 0..1 range that survives an 8-bit encode, while radiance has no fixed range, so fitting it into 8 bits requires a per-frame percentile stretch whose mapping changes every frame — a picture, not a measurement.

#### Live reflectance stream (MJPEG)

`GET /api/camera/<serial>/stream-reflectance` serves 8-bit per-band reflectance as `multipart/x-mixed-replace` — an M3C as a 3-channel JPEG, an M3M as greyscale. Query params: `fps` (default 10), `jpeg_q` (default 95), `max_h` (0 = native), `band` (one named band, as above), `align` (`1` to co-register — same rules as the SDK's `align`). Each part carries `X-Chloros-Group-Id`, `X-Chloros-Frame-Id`, `X-Chloros-Timestamp-Ns`, `X-Chloros-Bands`, `X-Chloros-Pixel-Scale`, `X-Chloros-Aligned` and `X-Chloros-Reflectance-Clipped`. **Parts from different cameras carrying the same `X-Chloros-Group-Id` are the same trigger** — that is how several per-camera streams of one array are reassembled, and it is guaranteed server-side because array members are served from the synced frame group (see [One trigger per read](#one-trigger-per-read--group_id)). It returns `409` when reflectance is unavailable at connect time and ends the stream if it becomes unavailable mid-run, so a stalled stream means "no longer reflectance" rather than "still fine, just static".

> **JPEG is lossy — these pixels are a measurement.** Compression error lands directly in the reflectance values and is worst at edges, which is where material boundaries are. Quality defaults to 95 for that reason. If you need the numbers intact, use `grab(processing="reflectance")` / `frame_stream(...)`, which return the same product with no codec in the path (and `bit_depth=8` there if wire cost, not the codec, is the constraint).

> **EXIF carried through to the export.** `process()` copies the source capture's GPS block **and its ExifIFD** onto every product, so exports carry `FocalLength`, `FNumber`, `ExposureTime`, `ISO`, `DateTimeOriginal` and `CameraSerialNumber` as well as the georeferencing. `FocalLength` is what Pix4D solves ground sample distance from — without it the reconstruction falls back to a wildly wrong scale (a measured case turned a 411 m site into a 47.8 km one). The copy is deliberately not `-all:all`: IFD0's structural tags break LATTICE output, and `ExifImageWidth`/`Height` are excluded because they describe the source capture rather than the exported raster.

Capture-stage sub-flags (apply to the radiometric levels — `radiance`, `reflectance`, `display`):

| Flag                  | Default | Meaning                                                                |
| --------------------- | ------- | ---------------------------------------------------------------------- |
| `apply_calibration`   | `True`  | DSNU + flat-field + 3x3 unmix + NIST radiometric scale.                |
| `apply_white_balance` | `True`  | WB LUT. DLS-aware when a DAQ is bound to the cam.                      |
| `apply_index`         | `False` | Vegetation index evaluation.                                           |
| `index_expression`    | `None`  | Override formula. Non-empty → auto-enables index.                      |
| `annotated`           | `False` | Overlay GUI decorations (zebra/grid/peaking). Not available for `raw`. |

### `ArrayHandle`

```python
arr = proj.arrays["main_rig"]

# Single synced capture group
files = arr.capture("./out", format="tiff", processing="reflectance")
# → {"213800234": "/path/to/x.tif", "214000533": "/path/to/y.tif", ...}

# Multi-level: each serial's value becomes an ordered LIST, not a str
files = arr.capture("./out", processing="all")
# → {"213800234": ["/raw.tif", "/debayered.tif", ...], "combined": "/idx.tif"}

# Smart capture (wait for AE to settle)
result = arr.capture_smart(
    "./out", processing="reflectance",
    settle_timeout_s=5.0,
    stability_window_s=1.5,
    exposure_tolerance_pct=5.0,
)
print(result["frames"], result["settle"])

# In-memory grab: {serial: numpy array}
frames = arr.grab(processing="debayered")
frames = arr.grab(processing="radiance", with_metadata=True)

# Stream-to-disk loop
arr.stream(count=60, output_dir="./stream", fps=5, processing="raw")

# Frame-iterator (tolerates per-cam drops; great for downstream analysis pipelines)
for frames in arr.frame_stream(processing="radiance", fps=5, count=100):
    if "213800234" in frames:
        my_analysis_pipeline(frames["213800234"])

# Preview iterator (live MJPEG-equivalent; tolerates partial cycles)
counts = arr.preview_stream("./preview", fps=3.0, duration=30.0)
print(counts)  # frames written per serial
```

> **The return type is `CapturePathMap`, not `Dict[str, str]`.** `chloros_sdk.CapturePathMap` is `Dict[str, Union[str, List[str]]]`: a single-level `processing` gives each serial one path, while a multi-level one (`"all"`, or an explicit `levels` list) gives it the **ordered list** of every product saved for that camera. A live combined composite, if one was streaming, arrives under the extra `"combined"` key rather than under a serial. Code that assumes `str` breaks on the list form without any type checker objecting — the annotation said `Dict[str, str]` for a while after the list form shipped, which is why the alias exists. Normalise when you want the flat form:
>
> ```python
> paths = arr.capture(processing="all")
> flat = [p for v in paths.values()
>         for p in (v if isinstance(v, list) else [v])]
> ```

### Array Alignment

`ArrayHandle` exposes the full alignment surface. Profiles are session-only by default — call `export_alignment()` explicitly to persist.

```python
from chloros_sdk import AlignmentSpec

arr = proj.arrays["main_rig"]

# Defaults: ORB / affine / one synced snapshot — same as the GUI's auto-cal
result = arr.calibrate_alignment()
print(result["profile"]["rms_residual_px"])

# Custom spec for tough scenes (low-contrast canopy)
spec = AlignmentSpec(
    method="feature_orb",         # feature_orb / feature_akaze / phase_correlation / checkerboard / manual
    model="rigid",                # translation / rigid / affine / homography
    num_frames=5,
    max_features=8000,
    ratio_threshold=0.7,
    ransac_threshold_px=2.0,
    min_matches=30,
    max_reproj_err_px=2.0,
)
arr.calibrate_alignment(spec)

# Or tweak one knob at a time
arr.calibrate_alignment(num_frames=3, model="affine")

# Inspect / manipulate
status = arr.alignment_status()
arr.tweak_alignment("214701292", dx=2.5, dy=-1.0, rotation_deg=0.0, scale=1.0)
arr.export_alignment("/tmp/main_rig_alignment.json")
arr.import_alignment("/tmp/main_rig_alignment.json", validate=True)
arr.clear_alignment()
```

#### Connect-Time Alignment

`connect_all(align=...)` can auto-align every array at connect:

```python
# Align every array with defaults
proj.connect_all(align=True)

# Per-array control
proj.connect_all(align={
    "main_rig": AlignmentSpec(num_frames=5, model="affine"),
    "side_rig": True,             # use defaults
    "verify_rig": False,          # skip
})
```

Falls back to `project.json["config"]["auto_align_on_connect"]` when unspecified.

### `SensorHandle`

```python
spectrum = proj.sensors["Sky"].read()
# (spectrum_list, is_saturated, integration_time, x, y, z) — matches the
# daq_sdk add_spectrum_callback signature.
```

***

## Direct Hardware (Backend-Free)

When you want zero dependency on the backend (CI, headless robots, embedded), import `lattice_sdk` and `daq_sdk` directly — both are re-exported by `chloros_sdk`. Guard on `CAMERA_AVAILABLE` / `DAQ_AVAILABLE`: `lattice_sdk` is in the PyPI package (but needs the Arena SDK runtime present), while `daq_sdk` ships only with the desktop install.

```python
from chloros_sdk import (
    # cameras
    LatticeCamera, CameraSettings, PRESETS, CameraPool,
    Calibration, CalibrationCoefficients, FilterModel, list_filters,
    DLS, NetworkDiagnostics, gpu_info, gpu_available,
    # discovery
    discover_cameras, discover_cameras_via_backend,
    # exceptions
    LatticeError, CameraNotFoundError, StreamError, CaptureError,
    CalibrationError, NetworkError, DLSError,
)

# Find a camera and capture in one go
cams = discover_cameras(timeout_ms=3000)
print(cams)

settings = PRESETS["high_quality"]
with LatticeCamera(serial="213800234", settings=settings) as cam:
    result = cam.capture(output_dir="./out", format="tiff")
    print(result.filepath, result.width, result.height)
```

**Presets and the trigger**

Three of the four presets **free-run**: the camera exposes continuously and a `capture()` returns the next frame. `triggered` is the exception — it arms the camera for a hardware edge on Line 2, so it captures nothing until one arrives.

| Preset         | Trigger           | Use it when                                                           |
| -------------- | ----------------- | --------------------------------------------------------------------- |
| `default`      | free-run          | general use                                                           |
| `high_speed`   | free-run          | 8-bit, 60 fps cap, short exposure                                     |
| `high_quality` | free-run          | 12-bit, no fps cap — the usual choice for stills                      |
| `triggered`    | **armed, Line 2** | the camera is wired into an M8 sync cable and something else fires it |

If you pick `triggered` (or set `trigger_mode="On"` yourself) with nothing driving Line 2, every `capture()` will time out — correctly, since you asked the camera to wait. The SDK explains this when it happens; see [SC\_ERR\_TIMEOUT during capture](#direct-hardware-backend-free).

> **Note — "GVSP probe" / `SC_ERR_TIMEOUT -1011` messages on connect are not errors.** On connect the SDK tries to negotiate **jumbo frames** (9000-byte GVSP packets) for higher throughput. On a direct point-to-point NIC link (e.g. a link-local `169.254.x.x` address) the network usually can't carry jumbo frames, so this probe times out and logs lines such as:
>
> ```
> [Network] GVSP probe: unexpected error (TimeoutError: ... SC_ERR_TIMEOUT -1011)
> [Network] GVSP probe at 9000 did not deliver a complete buffer; reverting to ICMP-chosen size
> [Network] GVSP packet size: 1500 bytes (standard)
> ```
>
> This is the **designed fallback**: the SDK automatically reverts to standard 1500-byte packets and the camera keeps connecting normally (the `[chunk-enable …]` lines that follow are part of the normal connect sequence). Capture still works.
>
> You can skip this probe, but **it is not just a log-silencer — it turns jumbo frames off.** The camera answers Don't-Fragment pings only up to 1500 bytes no matter how good your network is, so the ping test alone can never find jumbo; this probe is the only thing that can. Disable it and the camera runs standard 1500-byte packets forever, on any network:
>
> ```bash
> CHLOROS_GVSP_PROBE_FALLBACK=0   # gives up jumbo — see the warning it prints
> ```
>
> Only worth it on a network you *know* can't carry jumbo, where it saves roughly a second of connect time per camera. Since it's a real trade rather than a cosmetic one, the SDK now says so when you use it:
>
> ```
> [Network] ⚠️ GVSP probe disabled (CHLOROS_GVSP_PROBE_FALLBACK=0) — staying at
> 1500 bytes, jumbo NOT tested. … if this network does carry it, you are giving
> up ~1.45x wire ceiling. Unset the variable to test for jumbo.
> ```
>
> **Leave it alone unless you have a reason.** Left enabled, every connect re-measures the network you actually have: plug into a jumbo-capable switch and the next connect picks jumbo up on its own, with nothing to configure and no restart.
>
> If you *want* the jumbo throughput, enable jumbo end-to-end (NIC MTU 9000 + a switch that passes them), or pin it with `CHLOROS_GVSP_PACKET_SIZE_FORCE=9000` when you know the link carries it — though prefer a per-command `CHLOROS_GVSP_PACKET_SIZE_FORCE=9000 python …` over setting it permanently, since a pinned size skips the probe and stops adapting to the network in front of it. **Every** device in the path has to pass jumbo — including any PoE splitter or injector, which is the usual reason an otherwise jumbo-capable setup can't carry them.

> **`SC_ERR_TIMEOUT -1011` during `capture()` / `grab*()` is a different problem — that one is a real error.** The note above is only about `-1011` logged by the **connect-time probe**. The same error raised from a **capture** means the camera connected fine but is not sending any images:
>
> ```
> File ".../lattice_sdk/camera.py", line ..., in grab_frame_with_metadata
>   buffer = self._get_buffer(timeout)
> lattice_sdk.exceptions.CaptureError: Capture failed: ... SC_ERR_TIMEOUT -1011
> ```
>
> The give-away is a camera whose *control* channel is healthy — discovery works, settings and `[chunk-enable …]` writes all succeed — while *every* frame times out.
>
> **The usual cause is that the camera is armed for a hardware trigger.** With `trigger_mode="On"` and `trigger_source="Line2"`, the camera emits nothing at all until an electrical edge arrives on the M8 sync cable. If you have no cable driving that line, every grab waits forever. The camera is not broken and the network is fine — it is doing exactly what it was told.
>
> `CameraSettings()` and the `default` / `high_speed` / `high_quality` presets free-run, and a grab that times out while armed explains itself instead of printing a bare `-1011`. `PRESETS["triggered"]` arms Line2, by design.
>
> To force any camera to free-run:
>
> ```python
> settings = PRESETS["high_quality"]
> settings.trigger_mode = "Off"        # free-run; don't wait for an M8 edge
> ```
>
> If it still times out with `trigger_mode="Off"`, the camera really isn't delivering data — send us the log and `ip link show`.

#### Colour Profiles (RGB live preview) — `set_color_profile`

`LatticeCamera.set_color_profile(profile, custom_cct_k=None)` picks the display colour profile for the **live preview** on RGB cams (multispec cams ignore the setting):

| Profile       | Meaning                                                                                                                                                                       |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `raw`         | Bypass the radiometric chain entirely.                                                                                                                                        |
| `linear`      | DSNU + flat + WB, no CCM, no gamma.                                                                                                                                           |
| `natural`     | Linear + measured CCM + sRGB gamma, with the cheap finish only (chroma smoothing + highlight desaturation) — the realistic default.                                           |
| `enhanced`    | `natural` plus the full hub-parity finish (defringe, vibrance, CLAHE local contrast). Richer look at roughly **double the per-frame finish cost**, so a lower LIVE framerate. |
| `custom_temp` | `natural` but WB pinned to `custom_cct_k` Kelvin (DLS ignored; clamped to 2000–10000 K backend-side).                                                                         |

The profile is a **live-preview-only** speed/look knob: saved captures always get the full rich finish regardless of the selected profile, so picking `natural` to buy back frame time does not lower the quality of what lands on disk. An unknown profile raises `ValueError`; when a chloros backend is reachable the change is also POSTed to it so the next preview frame reflects it (direct-SDK users without a backend still get the settings mutation).

```python
with LatticeCamera(serial="214701292") as cam:   # RGB cam
    cam.set_color_profile("enhanced")            # richer look, lower LIVE fps
    cam.set_color_profile("custom_temp", custom_cct_k=5600)
```

#### Mono (M3M) Cameras and `Calibration`

A mono **M3M** camera (`M3M-<lens>-F<wavelength>`) is single-band: one grayscale plane, no Bayer mosaic, no 3×3 spectral-crosstalk matrix. `Calibration` recognises it and exposes an `is_mono` flag. Reflectance still applies as a per-band radiometric map (the unmix is the identity matrix), but multi-band maths on a single camera raises rather than returning nonsense:

```python
from chloros_sdk import Calibration, CalibrationError

calib = Calibration("M3M-L87-F685")
print(calib.is_mono)        # True  (False for any M3C / RGN Bayer cam)
print(calib.filter_type)    # 'mono'  (sentinel; not a real crosstalk key)

# NDVI needs two bands (Red + NIR); one mono band can't supply both.
try:
    calib.compute_ndvi(reflectance_frame)
except CalibrationError as e:
    print(e)   # "...single-band mono (M3M) camera. Combine multiple..."
```

To build a vegetation index from mono hardware, combine several M3M cams at different wavelengths into an aligned multi-band stack (see [Array Alignment](#array-alignment)) and compute the index across that stack instead of on one camera.

DAQ direct-mode:

```python
from chloros_sdk import (
    DAQUSensor, DAQMSensor, DAQESensor,
    SensorFleet, discover_all, DiscoveredSensor,
    apply_sensor_settings, SensorSettings,
)

for d in discover_all(timeout=3.0):
    print(d)

sensor = DAQUSensor(port="COM3")
sensor.connect()
apply_sensor_settings(sensor, settings={"integration_time_ms": 64, "frame_avg": 20})
sensor.start_streaming()
# ... sensor.add_spectrum_callback(your_callback) ...
sensor.stop()
```

> **`apply_sensor_settings` accepted keys** — exactly `integration_time_ms`, `frame_avg`, `ae_enabled`, `sunshine_diffuser_installed` (DAQ-E; deprecated in favour of `cap_id`), `filter_model` (DAQ-M), and `cap_id` (all DAQ kinds; `None`/`""`/`"none"` = bare sensor, no cap correction). Unknown keys are **silently ignored** — e.g. `{"integration_time": 64}` does nothing (it must be `integration_time_ms`). Returns `{"applied": [...], "errors": {...}}` and never raises.

`chloros_sdk` re-exports only the core surface used above. The full `daq_sdk` public API (22 names) adds the following — import them from `daq_sdk` directly:

```python
from daq_sdk import (
    DAQULogger, DAQMLogger, DAQELogger,     # rotating-file recorders (the ones the GUI uses)
    ConnectResult, FleetRecordResult,       # SensorFleet result types
    discover_all_detailed, build_sensor,    # detailed discovery + build-by-descriptor
    scan_eth_devices, DaqEControl,          # DAQ-E Ethernet scan + control channel
    scan_ble_devices, detect_ble_device, list_ble_devices,   # DAQ-M BLE discovery
    detect_port, list_serial_ports,         # DAQ-U serial-port discovery
    TcpSerial,                              # serial-over-TCP transport shim
)
```

***

## Exceptions

Catch the base class to handle "anything Chloros went wrong":

```python
import chloros_sdk

try:
    chloros_sdk.process_folder("/path/to/folder")
except chloros_sdk.ChlorosAuthenticationError:
    print("Run `chloros-cli login` first.")
except chloros_sdk.ChlorosLicenseError:
    print("Chloros+ subscription required.")
except chloros_sdk.ChlorosError as e:
    print(f"Chloros error: {e}")
```

> `ChlorosAuthenticationError` and `ChlorosConfigurationError` are exported at top level alongside the rest; they are also importable from `chloros_sdk.exceptions` as shown.

Hierarchy:

```
ChlorosError
├── ChlorosBackendError           (backend failed to start / unreachable)
├── ChlorosConnectionError        (HTTP transport failure)
├── ChlorosLicenseError           (subscription / tier gate)
├── ChlorosAuthenticationError    (login required)
├── ChlorosConfigurationError     (bad configure() / open_project() inputs)
└── ChlorosProcessingError        (pipeline failed)

ChlorosConnectError                (raised by connect_camera / connect_array /
                                    connect_daq_sensor only — derives from
                                    plain Exception, NOT from ChlorosError,
                                    so `except ChlorosError` will not catch it)

lattice_sdk exceptions:
LatticeError
├── CameraNotFoundError
├── CameraConnectionError
├── StreamError
├── CaptureError
├── CalibrationError
├── NetworkError
└── DLSError
```

***

## End-to-End Examples

### 1. Process a Folder with a Custom Progress Bar

```python
from chloros_sdk import ChlorosLocal

def progress(percent, message):
    bar = "#" * (percent // 5)
    print(f"\r[{bar:<20s}] {percent:3d}% {message}", end="", flush=True)

with ChlorosLocal() as cl:
    cl.create_project("FieldA_2026-05-26")
    cl.import_images("C:/DroneImages/Flight001", recursive=True)
    cl.configure(
        debayer="High Quality (Faster)",
        vignette_correction=True,
        reflectance_calibration=True,
        indices=["NDVI", "NDRE", "GNDVI", "SAVI"],
        export_format="TIFF (16-bit)",
    )
    cl.process(progress_callback=progress)
print()
```

### 2. Live LATTICE Array → Reflectance + DAQ Reference

```python
import chloros_sdk

# Open a paired sensor first so the array's reflectance step has an
# absolute reference. Smart-detect picks USB / BLE / ETH automatically.
with chloros_sdk.connect_daq_sensor() as daq:
    with chloros_sdk.connect_array([
            "213800234", "214000533", "214701288", "214701292"
    ]) as arr:
        # Smart capture: wait for AE to settle, then snap
        arr.capture("./out", processing="reflectance", smart=True)

        # Record the corresponding DAQ frames as ground truth
        daq.record_start(output_dir="./out", device_name="sky-reference")
        # ... do whatever capture campaign ...
        info = daq.record_stop()
        print(info["path"], info["rows"])
```

### 3. Project-Driven Capture Campaign

```python
import time, chloros_sdk

with chloros_sdk.open_project("/home/user/Chloros Projects/Field_A") as proj:
    report = proj.connect_all(verbose=True, align=True)
    if report["arrays"]["errors"]:
        raise SystemExit(f"Array(s) failed to connect: {report['arrays']['errors']}")

    rig = proj.arrays["main_rig"]

    # Re-align right before the campaign
    rig.calibrate_alignment(num_frames=5)
    rig.export_alignment("./alignments/main_rig.json")

    # 50 sequential single-frame captures at 2 fps
    for i in range(50):
        frames = rig.capture(
            output_dir=f"./out/frame_{i:04d}",
            processing="reflectance",
            apply_calibration=True,
            apply_white_balance=True,
        )
        time.sleep(0.5)

    # End-of-day: process the captured folder. process() accepts only
    # mode/wait/progress_callback/poll_interval — indices come from the
    # project's saved config (or set them via ChlorosLocal.configure()).
    proj.process()
```

### 4. Multi-Camera Frame-Stream → NumPy Pipeline

```python
import chloros_sdk
import numpy as np

with chloros_sdk.open_project("/path/to/proj") as proj:
    proj.connect_all()
    rig = proj.arrays["main_rig"]

    for frames in rig.frame_stream(
            processing="radiance",
            fps=5.0, count=300,
            apply_calibration=True,
            apply_white_balance=True):
        # frames is {serial: numpy_array}; cams not delivering this tick are omitted
        for serial, frame in frames.items():
            print(serial, frame.shape, frame.dtype, frame.mean())
```

### 5. Headless Direct-Hardware (No Backend) Capture Script

```python
from chloros_sdk import LatticeCamera, PRESETS, discover_cameras

cams = discover_cameras(timeout_ms=3000)
print(f"Found {len(cams)} cams")

settings = PRESETS["high_quality"]
for c in cams:
    with LatticeCamera(serial=c.serial, settings=settings) as cam:
        result = cam.capture(output_dir="./out", format="tiff")
        print(c.serial, result.filepath)
```

### 6. Capability Probe Before Connecting a 4-Cam Array

```python
import chloros_sdk

serials = ["214701288", "213800234", "214000533", "214701162"]

probe = chloros_sdk.analyze_array_network(
    master_serial=serials[0],
    slave_serials=serials[1:],
    width=2048, height=1536,
    pixel_format="BayerRG12",
)

if probe["status"] == "ok":
    arr = chloros_sdk.connect_array(
        serials, width=2048, height=1536, pixel_format="BayerRG12")
elif probe["status"] == "auto_capped_fps":
    r = probe["recommended"]
    print(f"Keeping resolution; capping trigger rate at "
          f"{r['recommended_target_fps']} fps")
    arr = chloros_sdk.connect_array(
        serials, width=2048, height=1536, pixel_format="BayerRG12",
        target_fps=r["recommended_target_fps"])
elif probe["status"] == "auto_shrunk":
    r = probe["recommended"]
    print(f"Auto-shrinking to {r['out_width']}x{r['out_height']} "
          f"binning={r['binning']} for sim-sync")
    arr = chloros_sdk.connect_array(
        serials,
        width=r["out_width"], height=r["out_height"],
        pixel_format=r["pixel_format"], binning=r["binning"])
elif probe["status"] == "needs_force_slip":
    print("Wire can't sustain sim-sync; falling back to slip mode")
    arr = chloros_sdk.connect_array(
        serials, force_tier="slip-emit-and-capture")
else:
    raise RuntimeError(f"Probe error: {probe.get('error')}")
```

### 7. Capture Recipe Equivalent (Pure Python)

The CLI's recipe DSL has a direct Python equivalent:

```python
import time, chloros_sdk

with chloros_sdk.open_project("/path/to/proj") as proj:
    proj.connect_all()
    cam = proj.cameras["FrontLeft"]
    rig = proj.arrays["main_rig"]
    sky = proj.sensors["Sky"]

    # apply
    # (CameraHandle has no direct apply method; use the underlying lattice_sdk
    #  helper or the backend's /api/camera/<sn>/apply-settings via requests)
    # For most cases just use cam.cam.set_exposure(...) in direct mode or
    # the GUI's saved settings via project.connect_all().

    # wait
    time.sleep(2)

    # capture
    cam.capture("pose_a/", format="tiff", processing="radiance")

    # stream
    rig.stream(count=60, fps=5, output_dir="stream/", processing="raw")

    # sensor read
    print(sky.read())
```

***

## Backend Auto-Start

The smart-connect entry points — `connect_camera`, `connect_array`, `connect_daq_sensor`, and `discover_lattice_cameras` — are thin HTTP clients that assume a backend is listening on `127.0.0.1:5000` (the smart-connect surface's default URL). When the GUI or CLI is already running, one is. From a bare script, there might not be — so these functions **auto-start the bundled backend binary** (window-less, the same way `ChlorosLocal` does) before their first call, then wait up to `backend_startup_timeout` for it to come up.

Rules:

* **Only a local URL is ever spawned.** A `backend_url` pointing at `localhost` / `127.0.0.1` / `[::1]` is eligible; any other host is assumed to be someone else's machine and is never spawned.
* **The backend is left running for reuse** (same as the CLI) — there's no implicit shutdown when your script exits. Re-running the script reuses the live backend.
* **Opt out with `auto_start_backend=False`** on any of those calls (e.g. when you've pointed at a remote backend, or you manage the backend lifecycle yourself).

```python
import chloros_sdk

# Fresh shell, no backend running, no GUI open — this still works:
with chloros_sdk.connect_camera("213800234") as cam:   # spawns the backend
    cam.capture("output/")

# Remote backend (via tunnel — see Remote-Backend Mode): don't spawn one locally
arr = chloros_sdk.connect_array(serials,
                                backend_url="http://127.0.0.1:5000",
                                auto_start_backend=False)
```

If the bundled binary can't be located or started, the subsequent HTTP call raises an actionable, **platform-aware** `ChlorosConnectError` rather than a bare connection-refused trace — on Windows it points you at the desktop app or a `chloros-cli` command; on Linux (no GUI) it points you at a `chloros-cli` command or the `.deb`.

***

## Environment & Headers

The SDK marks every backend HTTP call with `X-Chloros-Client: sdk`. The backend applies SDK/CLI licensing rules (login **and** a paid Chloros+ plan required) rather than the GUI free-tier path. This is set automatically at import time — you don't need to do anything.

`http://localhost` and `http://127.0.0.1` are detected as the local backend. Calls to other hosts (e.g. your own analytics service) are left untouched.

Override the backend URL by passing `backend_url=` (or `api_url=` on `ChlorosLocal`):

```python
chloros_sdk.connect_camera("213800234", backend_url="http://127.0.0.1:5000")
chloros_sdk.connect_array(serials, backend_url="http://127.0.0.1:5000")
chloros_sdk.connect_daq_sensor(eth_host="daq-e-1.local",
                                backend_url="http://127.0.0.1:5000")
chloros_sdk.ChlorosLocal(backend_url="http://127.0.0.1:5000")
```

(A non-loopback `backend_url` only reaches a source/dev backend — shipped backends bind loopback only; see Remote-Backend Mode for the tunnel pattern.)

***

## Versioning & Compatibility

* SDK version is exposed as `chloros_sdk.__version__`.
* The SDK pins behaviour to the bundled backend version. Mixing an older SDK with a newer backend usually works (forward-compatible endpoints), but mixing a newer SDK with an older backend may surface `404` errors on new endpoints — upgrade the desktop app to match.
* The smart-connect surface (`connect_camera` / `connect_array` / `connect_daq_sensor`) and the network-analysis endpoint return stable JSON schemas; new fields are additive.

***

## Troubleshooting Pointers

* **`ChlorosAuthenticationError: Login required`** → Run `chloros-cli login EMAIL PASSWORD` once on this machine, or sign in via the Chloros desktop app.
* **`ChlorosConnectError: No Chloros backend is running …`** → The smart-connect calls auto-start a local backend, so this only appears when the bundled binary can't be found/started (e.g. a pip-only host with no desktop package). The message is platform-aware: on Windows open the desktop app or run any `chloros-cli` command; on Linux run a `chloros-cli` command (no GUI exists) or install the `.deb`. For a remote backend, pass `backend_url=` (and `auto_start_backend=False`).
* **`CAMERA_AVAILABLE == False`** at import → `lattice_sdk` failed to load (typically the Arena SDK runtime DLLs aren't installed). The non-camera surface still works.
* **Array connect returns sub-native resolution** → The backend's smart-prep auto-shrinks frame size to fit the wire. Use `analyze_array_network()` to see why, then either upgrade the link, accept the shrink, or pass `force_tier="slip-emit-and-capture"` for sequential capture. The shrink safety-net does **not** cover aggregate over-subscription (`oversubscribed: true`, fps fields 0): too many cams for the wire cannot be fixed by binning/ROI — reduce the camera count, enable jumbo frames, or move to a faster NIC (see [Over-Subscription](#over-subscription-the-per-cam-floor)).
* **`analyze_array_network()` reports the NIC RX ring as tiny (\~0.26 MB) / connect gates with "FRAMES WILL DROP"** → The host NIC's receive ring is at its default (often reset to 32 after a NIC driver update). On a Realtek USB 10GbE adapter set `ReceiveBufferLen=256` and `PendingReceives=64` (elevated), then restart the backend so it re-reads the ring. Full procedure: [CLI Reference → Host NIC Setup & Tuning](/chloros/reference-cli-and-sdk/cli-reference.md#host-nic-setup--tuning-lattice-arrays).
* **Host hangs on restart/shutdown, later WMI `Invalid class` errors / NIC won't enable** → Outdated USB 10GbE driver causing `DRIVER_POWER_STATE_FAILURE` (BSOD `0x9F`). Update the adapter driver to a current version (≥ 2026) and re-apply the receive-ring settings. See [CLI Reference → Host NIC Setup & Tuning](/chloros/reference-cli-and-sdk/cli-reference.md#host-nic-setup--tuning-lattice-arrays).
* **Reflectance refused** → A live DAQ must be bound to the cam (or array) for absolute-scale reflectance. Either bind via the GUI or use `processing="radiance"` (W/m²/sr/nm) which doesn't require a paired sensor.
* **`smart=True` capture takes longer than expected** → AE convergence depends on scene dynamics; tighten `exposure_tolerance_pct` or shorten `stability_window_s` if you want a faster (less-stable) trigger.

***

## See Also

* [CLI Reference](/chloros/reference-cli-and-sdk/cli-reference.md) — every CLI subcommand mirrors an SDK call.
* [DAQ Sensor Guide](/chloros/daq-light-sensors/daq.md) — sensor-specific wiring, calibration, and recording rules.
* Online docs: `https://mapir.gitbook.io/chloros/api-python-sdk`
