Metadata-Version: 2.4
Name: ohmstar
Version: 0.1.0
Summary: Control LabMetric's ohmSTAR and ohmSTAR+ programmable resistance boxes from Python.
Project-URL: Homepage, https://www.labmetric.com
Author: LabMetric LC
License: Proprietary
License-File: LICENSE
License-File: NOTICE
Keywords: instrument control,labmetric,ohmstar,programmable resistance,rtd,serial,thermistor
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Science/Research
Classifier: License :: Other/Proprietary License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering
Classifier: Typing :: Typed
Requires-Python: >=3.8
Requires-Dist: pyserial>=3.5
Provides-Extra: console
Requires-Dist: colorama>=0.4.6; (platform_system == 'Windows') and extra == 'console'
Provides-Extra: dev
Requires-Dist: mypy>=1.8; extra == 'dev'
Requires-Dist: pytest-cov>=4; extra == 'dev'
Requires-Dist: pytest>=7; extra == 'dev'
Requires-Dist: types-pyserial; extra == 'dev'
Description-Content-Type: text/markdown

# ohmstar for Python

Drive LabMetric's **ohmSTAR** and **ohmSTAR+** programmable resistance boxes from a Python script. Set a resistance, set the resistance a sensor would have at a temperature, and read or load the box's user tables. It needs Python 3.8 or newer (3.8 is the last for Windows 7) and one package, `pyserial`.

There is a mock box in the package, so you can write and test scripts before you have a box on your desk.

## Five-minute start

### 1. Install

This package is not published yet. From a copy of the project:

```bash
python -m venv .venv
.venv/bin/pip install ./clients/python      # on Windows: .venv\Scripts\pip
```

### 2. Try it with no hardware

`MockBox` pretends to be a box. It follows the same rules as the mock box the ohmSTAR Companion app is tested with.

```python
from ohmstar import Box, MockBox

with Box(MockBox("ohmSTAR+")) as box:
    print(box.model, box.firmware)      # ohmSTAR+ 6.1

    box.set(10_000)                     # ohms, or text such as "10k" or "1.5M"
    print(box.last_sent)                # 10000.0, what was sent (not a reading)

    box.set(138.505)                    # rounded to the box's step, halves up
    print(box.last_sent)                # 138.51

    try:
        box.set("6M")                   # too high for an ohmSTAR+
    except ValueError as problem:
        print(problem)                  # Nothing was sent. 6 megohms is out of reach. ...
```

### 3. Connect a real box

Plug in the box, switch it on, close the ohmSTAR Companion app (only one program can use a box at a time), and change one line:

```python
from ohmstar import Box

with Box.connect() as box:              # finds the one box on USB
    print(box.model, box.firmware)
    box.set("4.7k")
```

`Box.connect()` looks only at FTDI USB serial adapters (vendor 0403, products 6001, 6014 and 6015), the chips the box is built on. It never sends anything to any other serial port. If it finds none, or more than one, it raises an exception that lists what it found. Then name the port yourself:

```python
from ohmstar import find_ports

for port in find_ports():               # only lists; nothing is sent
    print(port.describe())

box = Box.connect("COM4")               # or "/dev/ttyUSB0", "/dev/cu.usbserial-A1B2"
```

After opening a port, the only things sent are `Z` and `[` to ask what is there. Anything that does not answer as an ohmSTAR or ohmSTAR+ is refused and closed. On macOS the `/dev/cu.*` name is used, not `/dev/tty.*`. The port is opened at 9600 baud, 8 data bits, no parity, 1 stop bit.

### 4. Set what a sensor would read

```python
import ohmstar
from ohmstar import Box, MockBox

for found in ohmstar.sensors("10k ntc")[:3]:        # search the library of 128 curves
    print(found.id, found.x_min, found.x_max, found.x_unit)

# set_sensor refuses a name that matches several different sensors, so pick from the search.

with Box(MockBox("ohmSTAR+")) as box:
    result = box.set_sensor("pt100", 37.2)          # degrees C by default
    print(result.ohms)                              # 114.46
    print(result.sensor.name, result.others)        # Pt100 a0.00385 2

    box.set_sensor("pt100", 98.6, unit="F")         # degrees F
    box.set_sensor("pot 1k linear b", 50)           # position sensors take percent
    box.set_sensor("strain 120ohm gf2.00", 1000)    # strain gauges take microstrain
```

`set_sensor` tells you which sensor it picked and how many others the name also matched. Use a full id such as `"Omega/Pt100_a0.00385"`, or pass a `Sensor` from `ohmstar.sensors()`, when you want no doubt. A name like `"pt100"` will not pick a Pt1000. Between table rows, thermistors (NTC) are interpolated in log, everything else in a straight line. A value outside a table's range raises `ValueError`; the library never extends a curve past its ends.

### 5. Read and load tables

The box has user tables in slots 1 to 9. Slot 0 is the calibration table and nothing in this package can reach it.

```python
from ohmstar import Box, MockBox, SlotInUseError, Table

with Box(MockBox()) as box:
    table = Table.of("Pt100", "C", [(0, 100.0), (100, 138.51)])
    box.upload_table(2, table)                  # erase, write, read back, compare
    print(box.read_table(2).points)

    mine = Table.of("Other", "C", [(0, 1), (10, 2)])
    try:
        box.upload_table(2, mine)               # slot 2 is in use
    except SlotInUseError as problem:
        print(problem)                          # Slot 2 holds "Pt100" with 2 points. Pass overwrite=True ...
    box.upload_table(2, mine, overwrite=True)
```

A table needs a name and units of up to 20 plain characters (no comma, never blank), 2 to 256 points, each column only rising or only falling, user values within 12 million, and ohms from 0.7 to 12 million. `Table.problems()` lists everything wrong with one. `Table.from_file(path)` and `table.to_file(path)` use the table file format from the ohmSTAR manual.

## Safety facts

- **The box's `y` does not mean "accepted".** For `r` (set resistance) the box answers `y` to everything, including `rabc`, `r-5` and `r30000000`. What a bad value does to the output is unknown. So `Box.set` checks the value against the connected model *before* sending, raises `ValueError` naming the range, and rounds to the model's step (0.1 ohm on the ohmSTAR, 0.01 ohm on the ohmSTAR+, halves up, so 138.505 becomes 138.51). A lowercase `m` is refused, not guessed. Numbers are never sent in scientific notation.
- **`last_sent` is not a reading.** The box cannot report its output. `box.last_sent` is only the last value *this script* sent. It is `None` until something is sent, and it goes back to `None` after a timeout or an odd reply, because the box's state is then unknown. Someone pressing keys on the box, or another program, changes the output without `last_sent` knowing. Check the LCD when it matters.
- **A timeout may mean the box switched itself off** to save power. The exception says so. Switch the box on and try again.
- **Table commands are different:** the box really does refuse a bad `T` command, by answering `G`. A refusal raises `BoxRefusedError`. After an upload the table is read back and compared, because a `y` only means a line was received.
- **A table name can never be blanked** on a real box. A slot that has held a name never reads empty again, so `upload_table` treats any named slot as in use unless you pass `overwrite=True`.
- **There is deliberately no calibration method and no general write command.** `box.send_raw(line)` is for reads: `t,...`, `Z` and `[`. It refuses anything else unless you pass `allow_risky=True`. Even then it refuses control characters, overlong lines, calibration writes, and any write that is not to a table from 1 to 9.
- Commands go out one at a time. Several threads can share a `Box`; they take turns.

## Errors

| Problem | Exception |
|---|---|
| A value out of range, a bad table, a bad slot, an unknown sensor | `ValueError` (tables: `InvalidTableError`) |
| A raw command that is refused | `UnsafeCommandError` (a `ValueError`) |
| No box, or several boxes | `NoBoxFoundError`, `SeveralBoxesError` |
| The port will not open | `PortOpenError` |
| Not an ohmSTAR or ohmSTAR+ | `NotABoxError` |
| No answer in time | `BoxTimeoutError` |
| Cable pulled, port closed | `BoxConnectionError`, `BoxClosedError` |
| A table slot is in use | `SlotInUseError` |
| A table stopped part way, or did not read back the same | `UploadIncompleteError`, `TableMismatchError` |

All of them except the `ValueError` family inherit from `OhmstarError`.

## Writing tests for your scripts

`Box` accepts anything that works like a pyserial port: `write`, `read`, `in_waiting`, `reset_input_buffer` and `close`. `MockBox` is one. To test how your script copes with a box that has gone quiet:

```python
from ohmstar import Box, BoxTimeoutError, MockBox

mock = MockBox()
box = Box(mock, reply_timeout=0.2)
mock.switch_off()                       # the box stops answering
try:
    box.set(100)
except BoxTimeoutError:
    print("no answer")
```

`mock.state` shows what the mock box holds: the resistance it was set to, its tables and its calibration values.

## LabVIEW, MATLAB and other languages

This package is Python only. For anything else, open the serial port yourself and follow **docs/PROTOCOL.md** in the project's repository. It lists every command the box answers, what a real unit did when it was tested, and the traps above in more detail.

## Developing

```bash
python -m venv .venv
.venv/bin/pip install -e ".[dev]"
.venv/bin/pytest --cov                 # no hardware needed
.venv/bin/mypy
```

The tests never open a real serial port. The sensor library lives in `packages/tables/data` in the repository, and a copy is bundled in `src/ohmstar/data` so that the wheel carries it. After the library changes, run `python scripts/sync_data.py`; a test fails if the copy is out of date. `tests/fixtures/ts_agreement.json` holds what the TypeScript code gives for a sample of inputs, so a test can check that Python agrees. Regenerate it from the repository root with `npx vitest run --config clients/python/scripts/vitest.fixture.config.mts`.

## License

Proprietary. Copyright 2026 LabMetric LC. All rights reserved. See `LICENSE` and `NOTICE`. The sensor curves are nominal values derived from makers' published data; see `src/ohmstar/data/README.md`.
