Metadata-Version: 2.4
Name: sciglob
Version: 0.3.0
Summary: Python library for controlling SciGlob scientific instrumentation: Head Sensors, Trackers, Filter Wheels, and more
Author-email: Ashutosh Joshi <ajoshi@sciglob.com>
Maintainer-email: Ashutosh Joshi <ajoshi@sciglob.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/ashutoshjoshi1/SciGlob-Library
Project-URL: Documentation, https://github.com/ashutoshjoshi1/SciGlob-Library/blob/main/docs/API_REFERENCE.md
Project-URL: Repository, https://github.com/ashutoshjoshi1/SciGlob-Library.git
Project-URL: Issues, https://github.com/ashutoshjoshi1/SciGlob-Library/issues
Project-URL: Changelog, https://github.com/ashutoshjoshi1/SciGlob-Library/blob/main/CHANGELOG.md
Keywords: scientific-instrumentation,sensor-head,filter-wheel,tracker,motor-control,azimuth,zenith,atmospheric-monitoring,spectrometer,serial-communication,hardware-control,thp-sensor,bme280,sht4x,ms5607,mcp2221
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Science/Research
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Operating System :: POSIX :: Linux
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: MacOS
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Scientific/Engineering
Classifier: Topic :: Scientific/Engineering :: Atmospheric Science
Classifier: Topic :: Scientific/Engineering :: Physics
Classifier: Topic :: System :: Hardware
Classifier: Topic :: System :: Hardware :: Hardware Drivers
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pyserial>=3.5
Requires-Dist: pyyaml>=6.0
Provides-Extra: spectrometer
Requires-Dist: numpy>=1.20; extra == "spectrometer"
Provides-Extra: imu
Requires-Dist: ximu3>=1.0; extra == "imu"
Provides-Extra: camera
Requires-Dist: opencv-python>=4.5; extra == "camera"
Provides-Extra: minithp
Requires-Dist: hidapi>=0.14; extra == "minithp"
Provides-Extra: hardware
Requires-Dist: sciglob[camera,imu,minithp,spectrometer]; extra == "hardware"
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: pytest-cov>=4.0; extra == "dev"
Requires-Dist: pytest-mock>=3.10; extra == "dev"
Requires-Dist: black<27,>=25; extra == "dev"
Requires-Dist: ruff<0.17,>=0.16; extra == "dev"
Requires-Dist: mypy>=1.0; extra == "dev"
Requires-Dist: types-PyYAML>=6.0; extra == "dev"
Requires-Dist: isort>=5.12; extra == "dev"
Requires-Dist: pre-commit>=3.0; extra == "dev"
Requires-Dist: build>=1.0; extra == "dev"
Requires-Dist: twine>=4.0; extra == "dev"
Provides-Extra: docs
Requires-Dist: mkdocs>=1.5; extra == "docs"
Requires-Dist: mkdocs-material>=9.0; extra == "docs"
Requires-Dist: mkdocstrings[python]>=0.24; extra == "docs"
Provides-Extra: all
Requires-Dist: sciglob[camera,dev,docs,imu,minithp,spectrometer]; extra == "all"
Dynamic: license-file

<p align="center">
  <h1 align="center">🔭 SciGlob Library</h1>
  <p align="center">
    <b>One Python interface for every device in a SciGlob / Pandora-class instrument.</b><br/>
    Real drivers and simulation twins for trackers, filter wheels, spectrometers, sensor boxes, and more.
  </p>
</p>

<p align="center">
  <a href="https://github.com/ashutoshjoshi1/SciGlob-Library/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/ashutoshjoshi1/SciGlob-Library/actions/workflows/ci.yml/badge.svg"></a>
  <a href="https://pypi.org/project/sciglob/"><img alt="PyPI" src="https://img.shields.io/pypi/v/sciglob.svg?color=blue"></a>
  <a href="https://pypi.org/project/sciglob/"><img alt="Python versions" src="https://img.shields.io/pypi/pyversions/sciglob.svg"></a>
  <a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg"></a>
  <a href="https://github.com/psf/black"><img alt="Code style: black" src="https://img.shields.io/badge/code%20style-black-000000.svg"></a>
</p>

<p align="center">
  <a href="#-installation">Installation</a> ·
  <a href="#-quick-start">Quick Start</a> ·
  <a href="#-supported-hardware">Supported Hardware</a> ·
  <a href="#-device-examples">Device Examples</a> ·
  <a href="#-architecture">Architecture</a> ·
  <a href="#-documentation">Documentation</a>
</p>

---

## ✨ Highlights

- **One facade, whole instrument** — `Instrument` opens every device from a single YAML (or Pandora IOF) file, degrades gracefully when hardware is unplugged, and reports a per-device status map.
- **No wire protocols required** — high-level methods like `tracker.move_to(zenith=45, azimuth=180)` instead of raw `TRb4500,-1200` strings.
- **Every driver has a simulation twin** — develop and test the full stack with zero hardware attached (`simulated=True`).
- **Lean core** — the base install needs only `pyserial` + `pyyaml`; vendor-heavy subsystems live behind extras.
- **Typed, tested, cross-platform** — fully type-hinted (`py.typed`), 400+ tests, CI on Linux, macOS, and Windows across Python 3.9–3.12.

---

## 📦 Installation

```bash
pip install sciglob
```

Optional hardware subsystems are isolated behind extras:

| Extra | Installs | Enables |
|---|---|---|
| `sciglob[spectrometer]` | `numpy` | Avantes AvaSpec spectrometer (DLL ships with the instrument) |
| `sciglob[imu]` | `ximu3` | Head-mounted xIMU3 IMU |
| `sciglob[camera]` | `opencv-python` | Camera capture |
| `sciglob[minithp]` | `hidapi` | Mini THP unit (USB-HID, not serial) |
| `sciglob[hardware]` | all four above | Full hardware stack |

### From source

```bash
git clone https://github.com/ashutoshjoshi1/SciGlob-Library.git
cd SciGlob-Library
pip install -e ".[dev]"
```

---

## 🚀 Quick Start

### The `Instrument` facade — talk to all hardware at once

```python
from sciglob import Instrument

# Open a whole instrument from one config; missing devices degrade gracefully.
inst = Instrument.from_yaml("pandora101.yaml")   # or Instrument.from_iof("Pandora101_OF.txt")
with inst:
    inst.tracker.move_to(zenith=45.0, azimuth=180.0)
    inst.filter_wheel_1.set_filter("U340")

    inst.spectrometer.set_integration_time(200)   # ms
    spectrum = inst.spectrometer.measure(10)       # 10 accumulated cycles

    rh = inst.sbhs.get_humidity()
    inst.head_sensor.spec_power_cycle(1)           # auto-marks the spectrometer first

    print(inst.status())   # {'head_sensor': {'state': 'connected'}, ...}
```

### No hardware? No problem

Every device has a simulation twin — run the entire instrument in software:

```python
inst = Instrument.from_yaml("pandora101.yaml", simulated=True)
```

### A single device in three lines

```python
from sciglob import HeadSensor

with HeadSensor(port="/dev/ttyUSB0") as hs:
    hs.tracker.move_to(zenith=45.0, azimuth=180.0)
```

---

## 🔌 Supported Hardware

| # | Subsystem | Class | Protocol | Extra |
|---|-----------|-------|----------|-------|
| 1 | **Head Sensor** (SciGlobHSN1/HSN2) | `HeadSensor` | RS-232 | — |
| 2 | **Tracker** (Directed Perceptions, LuftBlickTR1) | `Tracker` | via Head Sensor | — |
| 3 | **Filter Wheels** FW1/FW2 + Shadowband | `FilterWheel`, `Shadowband` | via Head Sensor | — |
| 4 | **Temperature Controllers** (TETech1/TETech2/TETech1090) | `TemperatureController` | RS-232 | — |
| 5 | **Humidity Sensor** (HDC2080EVM) | `HumiditySensor` | RS-232 | — |
| 6 | **GPS / Positioning** (GlobalSat, Novatel GPS+gyro) | `GlobalSatGPS`, `NovatelGPS` | RS-232 | — |
| 7 | **SBHS** — Spec-Box Humidity Sensor (ESP32) | `SBHS` | RS-232 (JSON) | — |
| 8 | **ASB** — Air Sensors Box (ESP32, dual BME280 + MPRLS) | `ASB` | RS-232 (JSON) | — |
| 9 | **SRB** — SciGlobSRB1 sensors-reading board | `SRB` | RS-232 | — |
| 10 | **Direct-RS485 Tracker** (Oriental Motor AZ/AZD) | `RS485Tracker` | RS-485 Modbus RTU | — |
| 11 | **Relay Board** (Samirob 4-channel) | `RelayBoard` | RS-232 (binary) | — |
| 12 | **Avantes Spectrometer** (AvaSpec DLL) | `AvantesSpectrometer` | ctypes/USB | `[spectrometer]` |
| 13 | **Camera** (OpenCV / simulation) | `Camera` | OpenCV | `[camera]` |
| 14 | **Head-mounted IMU** (xIMU3) | `IMU` | xIMU3 SDK | `[imu]` |
| 15 | **THP Sensor** (BME280 box) | `THPSensor` | RS-232 (JSON, `p`) | — |
| 16 | **Mini THP** (SHT4x + MS5607 on MCP2221A) | `MiniTHP` | USB-HID → I²C | `[minithp]` |

---

## 🧪 Device Examples

<details>
<summary><b>Head Sensor — tracker, filter wheels, internal sensors</b></summary>

```python
from sciglob import HeadSensor

with HeadSensor(port="/dev/ttyUSB0") as hs:
    print(f"Device: {hs.device_id}")
    print(f"Type: {hs.sensor_type}")

    # Internal sensors (SciGlobHSN2 only)
    if hs.sensor_type == "SciGlobHSN2":
        print(f"Temperature: {hs.get_temperature()}°C")
        print(f"Humidity: {hs.get_humidity()}%")
        print(f"Pressure: {hs.get_pressure()} mbar")

    # Tracker (azimuth/zenith motors)
    tracker = hs.tracker
    tracker.move_to(zenith=45.0, azimuth=180.0)
    print(f"Position: {tracker.get_position()}")

    # Filter wheel
    fw1 = hs.filter_wheel_1
    fw1.set_filter("OPEN")
    print(f"Current filter: {fw1.current_filter}")
```

</details>

<details>
<summary><b>Tracker — absolute/relative moves, homing, alarms</b></summary>

```python
# Movement in degrees
tracker.move_to(zenith=45.0, azimuth=180.0)                     # Absolute
tracker.move_relative(delta_zenith=10.0, delta_azimuth=-20.0)   # Relative
tracker.pan(azimuth=90.0)   # Azimuth only
tracker.tilt(zenith=30.0)   # Zenith only

# Movement in steps
tracker.move_to_steps(zenith_steps=4500, azimuth_steps=-1200)

# Position
zenith, azimuth = tracker.get_position()              # Degrees
azi_steps, zen_steps = tracker.get_position_steps()   # Steps

# Special commands
tracker.home()          # Go to home position
tracker.park()          # Go to parking position
tracker.reset()         # Soft reset
tracker.power_reset()   # Power cycle

# LuftBlickTR1 specific
if tracker.is_luftblick:
    temps = tracker.get_motor_temperatures()
    alarms = tracker.get_motor_alarms()
    tracker.check_alarms()  # Raises if an alarm is present
```

</details>

<details>
<summary><b>Filter Wheels — select by name or position</b></summary>

```python
fw1.set_position(5)        # Select by position (1-9)
fw1.set_filter("U340")     # Select by filter name

print(fw1.position)        # Current position number
print(fw1.current_filter)  # Current filter name

print(fw1.get_filter_map())         # {1: "OPEN", 2: "U340", ...}
print(fw1.get_available_filters())  # ["OPEN", "U340", ...]

fw1.reset()                # Reset to home
```

</details>

<details>
<summary><b>Temperature Controller — TETech1/TETech2/TETech1090</b></summary>

```python
from sciglob import TemperatureController

with TemperatureController(port="/dev/ttyUSB1", controller_type="TETech1") as tc:
    print(f"Current: {tc.get_temperature()}°C")
    print(f"Setpoint: {tc.get_setpoint()}°C")

    tc.set_temperature(25.0)
    tc.enable_output()
    tc.disable_output()
```

</details>

<details>
<summary><b>ESP32 sensor boxes — SBHS &amp; ASB</b></summary>

```python
from sciglob import SBHS, ASB

with SBHS(port="COM8") as sbhs:
    print(sbhs.get_temperature(), sbhs.get_humidity(), sbhs.get_pressure())

with ASB(port="COM9") as asb:
    print(asb.get_ambient_pressure())   # MPRLS
```

</details>

<details>
<summary><b>THP sensors — BME280 box &amp; Mini THP</b></summary>

Two different THP units, one reading API. The **BME280 THP box** is a serial
device; the **Mini THP** is USB-HID (an MCP2221A bridge carrying an SHT4x for
temperature/humidity and an MS5607 for pressure). Both answer
`get_temperature()` / `get_humidity()` / `get_pressure()` / `get_readings()`, so
either can drop into the same code.

```python
from sciglob import MiniTHP, THPSensor

# BME280 THP box — RS-232, one 'p' command returns the whole record
with THPSensor(port="COM5") as thp:
    print(thp.get_readings())      # {'temperature': .., 'humidity': .., 'pressure': ..}
    print(thp.sensor_id)           # 'BME280'

# Mini THP — USB-HID; needs pip install "sciglob[minithp]"
with MiniTHP() as mini:            # first unit found; no port to pick
    print(mini.get_readings())
    print(mini.port)               # the COM port this unit is filed under, if known
```

Several Mini THP units at once, each matched to its own COM port:

```python
for info in MiniTHP.discover():
    print(info)                    # 'MCP2221 USB-I2C serial 0002247961 on COM7'

units = MiniTHP.open_all()         # skips any unit that fails to start
for unit in units:
    print(unit.serial_number, unit.get_readings())
```

Units ship with serial-number reporting switched off, so the OS files identical
units by socket position and they cannot be told apart. `enable_own_com_port()`
switches it on — stored in the unit, so it survives moving to another machine.
Unplug and replug afterwards:

```python
with MiniTHP() as unit:
    if unit.reports_serial is False:
        unit.enable_own_com_port()
```

Scan for serial THP boxes:

```python
print(THPSensor.discover())        # ['COM5'] — ports that answered
```

</details>

<details>
<summary><b>SRB sensors board &amp; relay board</b></summary>

```python
from sciglob import SRB, RelayBoard

# SciGlobSRB1 board
with SRB(port="COM11") as srb:
    print(srb.get_all_sensors())

# Samirob relay board
board = RelayBoard(port="COM12", nrelays=4)
board.connect()
board.on(1)
print(board.state(1))
board.off(1)
```

</details>

<details>
<summary><b>Direct-RS485 tracker — Oriental Motor AZ/AZD (Modbus RTU)</b></summary>

```python
from sciglob import RS485Tracker

# Same facade as the head-sensor Tracker
trk = RS485Tracker(port="COM10", zenith_slave=1, azimuth_slave=2)
trk.connect()
trk.home()
trk.move_to(zenith=30.0, azimuth=120.0)
```

</details>

<details>
<summary><b>GPS positioning — GlobalSat &amp; Novatel</b></summary>

```python
from sciglob import GlobalSatGPS, NovatelGPS

# Simple GPS
with GlobalSatGPS(port="/dev/ttyUSB3") as gps:
    pos = gps.get_position()
    print(f"Lat: {pos['latitude']}, Lon: {pos['longitude']}")

# GPS + gyroscope
with NovatelGPS(port="/dev/ttyUSB4") as gps:
    pos = gps.get_position()
    orient = gps.get_orientation()
    print(f"Yaw: {orient['yaw']}°, Pitch: {orient['pitch']}°")
```

</details>

<details>
<summary><b>Humidity sensor — HDC2080EVM</b></summary>

```python
from sciglob import HumiditySensor

with HumiditySensor(port="/dev/ttyUSB2") as hs:
    print(f"Temperature: {hs.get_temperature()}°C")
    print(f"Humidity: {hs.get_humidity()}%")
```

</details>

<details>
<summary><b>Spectrometer, camera &amp; IMU (extras)</b></summary>

```python
# Avantes spectrometer   — pip install "sciglob[spectrometer]"
from sciglob.spectrometers import AvantesSpectrometer, get_session

session = get_session()
session.init()
spec = AvantesSpectrometer(serial="1234", session=session)
spec.connect()
spec.set_integration_time(200)
spectrum = spec.measure(10)

# Camera                 — pip install "sciglob[camera]"
from sciglob.camera import Camera

with Camera(backend="opencv") as cam:
    frame = cam.capture()

# xIMU3 head IMU         — pip install "sciglob[imu]"
from sciglob.imu import IMU

with IMU(port="COM13") as imu:
    print(imu.get_readings())   # Roll/Pitch/Yaw/Temp/Battery
```

</details>

---

## 🏗 Architecture

```mermaid
flowchart TB
    APP["🖥 Your Application"]
    subgraph LIB["SciGlob Library"]
        INST["Instrument facade\n(YAML / IOF config · status map · graceful degradation)"]
        subgraph DRIVERS["Device drivers  +  simulation twins"]
            HS["HeadSensor\nTracker · FilterWheel"]
            IND["TemperatureController · HumiditySensor\nGPS · SBHS · ASB · SRB · THPSensor · RelayBoard"]
            EXT["AvantesSpectrometer\nCamera · IMU · MiniTHP"]
            RS485["RS485Tracker"]
        end
    end
    HW1["Head Sensor\nRS-232"]
    HW2["Independent devices\nRS-232"]
    HW3["USB: HID / DLL / SDK"]
    HW4["Oriental Motor AZ/AZD\nRS-485 Modbus RTU"]

    APP --> INST
    INST --> HS & IND & EXT & RS485
    HS --> HW1
    IND --> HW2
    EXT --> HW3
    RS485 --> HW4
```

Every driver talks through a shared connection core (port registry, timeouts, retries) and can be swapped for its simulation twin — so the same application code runs against real hardware or none at all.

<details>
<summary><b>Wire-protocol cheat sheet</b> (see the full <a href="SCIGLOB_COMMAND_REFERENCE.md">Command Reference</a>)</summary>

### Head Sensor
| Command | Response | Description |
|---------|----------|-------------|
| `?` | Device ID | Query identification |
| `HTt?` | `HT!<value>` | Temperature (÷100 = °C) |
| `HTh?` | `HT!<value>` | Humidity (÷1024 = %) |
| `HTp?` | `HT!<value>` | Pressure (÷100 = mbar) |

### Tracker
| Command | Response | Description |
|---------|----------|-------------|
| `TRp<steps>` | `TR0` | Pan (azimuth only) |
| `TRt<steps>` | `TR0` | Tilt (zenith only) |
| `TRb<azi>,<zen>` | `TR0` | Move both axes |
| `TRw` | `TRh<azi>,<zen>` | Query position |
| `TRr` | `TR0` | Soft reset |
| `TRs` | `TR0` | Power reset |

### Filter Wheel
| Command | Response | Description |
|---------|----------|-------------|
| `F1<1-9>` | `F10` | Set FW1 position |
| `F2<1-9>` | `F20` | Set FW2 position |
| `F1r` | `F10` | Reset FW1 |
| `F2r` | `F20` | Reset FW2 |

</details>

---

## 🛡 Error Handling

All exceptions derive from `SciGlobError`, so you can catch broadly or precisely:

```python
from sciglob import (
    HeadSensor,
    ConnectionError,
    TrackerError,
    PositionError,
    FilterWheelError,
    MotorAlarmError,
)

try:
    with HeadSensor(port="/dev/ttyUSB0") as hs:
        hs.tracker.move_to(zenith=100.0, azimuth=180.0)  # PositionError if out of limits

except ConnectionError as e:
    print(f"Connection failed: {e}")
except PositionError as e:
    print(f"Position out of range: {e.position} not in [{e.min_pos}, {e.max_pos}]")
except MotorAlarmError as e:
    print(f"Motor alarm on {e.axis}: code {e.alarm_code}")
except TrackerError as e:
    print(f"Tracker error: {e}")
```

---

## ⚙️ Configuration

```python
hs = HeadSensor(
    port="/dev/ttyUSB0",
    baudrate=9600,
    tracker_type="LuftBlickTR1",     # or "Directed Perceptions"
    degrees_per_step=0.01,           # 100 steps per degree
    motion_limits=[0, 90, 0, 360],   # [zen_min, zen_max, azi_min, azi_max]
    home_position=[0.0, 180.0],      # [zenith_home, azimuth_home]
    fw1_filters=["OPEN", "U340", "BP300", "LPNIR", "ND1", "ND2", "ND3", "ND4", "OPAQUE"],
    fw2_filters=["OPEN", "DIFF", "U340+DIFF", ...],
)
```

### Logging

```python
import logging

logging.basicConfig(level=logging.DEBUG)
logging.getLogger("sciglob").setLevel(logging.DEBUG)

# Or for specific components
logging.getLogger("sciglob.Tracker").setLevel(logging.DEBUG)
logging.getLogger("sciglob.serial").setLevel(logging.DEBUG)
```

---

## 📚 Documentation

| Document | Contents |
|---|---|
| [API Reference](docs/API_REFERENCE.md) | Full API documentation |
| [Quick Reference](docs/QUICK_REFERENCE.md) | Cheat sheet for common operations |
| [Platform Architecture](docs/PLATFORM_ARCHITECTURE.md) | Detailed system architecture |
| [Automation Guide](docs/AUTOMATION_GUIDE.md) | Routines and scheduled measurements |
| [Reliability](docs/RELIABILITY.md) | Hardening, retries, and failure modes |
| [Command Reference](SCIGLOB_COMMAND_REFERENCE.md) | Complete wire-protocol documentation |
| [Library Specification](SCIGLOB_LIBRARY_SPEC.md) | Full implementation specification |
| [Examples](examples/) | Runnable scripts: basic usage, full instrument, automation |

---

## 🤝 Contributing

1. Fork the repository and create a feature branch
2. Install dev dependencies: `pip install -e ".[dev]"`
3. Make your changes (with tests)
4. Verify the CI suite locally:
   ```bash
   ruff check sciglob tests
   black --check sciglob tests
   mypy sciglob --ignore-missing-imports
   pytest
   ```
5. Submit a pull request

---

## 📄 License

MIT License — see [LICENSE](LICENSE) for details.

<p align="center"><sub>Built by <a href="https://github.com/ashutoshjoshi1">SciGlob</a> for atmospheric-science instrumentation.</sub></p>
