A Python-based laser power calibration and control suite for microscopy systems. Monet automates calibration of laser power through attenuators (rotation mounts, NIDAQ, AOTF) and power meters, storing results in a SQLite database behind a FastAPI server or in a legacy Excel file. Named after the impressionist painter for his mastery of light intensity.
- Automated power calibration across multiple lasers and power levels
- Sinusoidal, linear, and polynomial curve fitting for attenuation mapping
- Three power-setting modes — combined (laser + attenuator), fixed-laser (adjust attenuator only), fixed-attenuator (adjust laser only)
- Closed-loop power setting with a PI controller against a power meter, with configurable tolerance and gains
- Interactive CLI for calibration, adjustment, and power setting
- PyQt6 GUI with a Calibrate / Set Power / Database tab set, including a live feedback-convergence plot
- Embeddable widget API (
monet.qt) — the GUI can be dropped into any other Qt application - Centralized database server for concurrent multi-microscope access
- Hardware abstraction layer with pluggable laser, attenuator, and power-meter drivers
- Migration tooling from legacy Excel databases to SQLite
- MicroManager integration: measured power is written into the acquisition comment when available
Python >= 3.10 with Anaconda (recommended):
conda create -n monet python=3.10
conda activate monetHardware drivers are loaded lazily, only when the corresponding hardware class is first instantiated. You can install Monet and run it (in test/simulation mode, with the headless test hardware, or as an embedded widget) without any of these. Install the ones you actually use:
- Kinesis rotation mount → Thorlabs Kinesis software (
msl-equipment) - Thorlabs PM100 power meter → Thorlabs Optical Power Monitor software (
ThorlabsPM100,pyvisa) - NI DAQ analog-output attenuator → NI-DAQmx driver (
nidaqmx) - Cobolt lasers →
pycobolt - Toptica iBeam lasers →
microscope - Beam path (filter wheels, shutters) → Micro-Manager +
pycromanager
All dependencies are declared in pyproject.toml. The base install covers the
CLI and analysis (and runs against the simulated Test* hardware); optional
extras add the GUI, the database server, and the real-instrument SDKs.
# Base install (development mode)
pip install -e .
# Optional extras — combine in one bracket as needed, e.g. ".[gui,server]"
pip install -e ".[gui]" # PyQt6 GUI (python -m monet gui)
pip install -e ".[server]" # FastAPI calibration server (python -m monet serve)
pip install -e ".[hardware]" # real-instrument SDKs (pyvisa, nidaqmx, ...)
pip install -e ".[dev]" # test / development tooling
pip install -e ".[all]" # everything abovePlace the power meter head above the objective, connect the power meter, and switch on the laser.
# Calibrate a microscope
python -m monet calibrate Voyager
# Set power interactively
python -m monet set Voyager
# Or launch the graphical interface
python -m monet gui VoyagerInside the interactive set shell:
(monet set) laser 561 # select the 561 nm laser
(monet set) mode fixed_attenuator # keep attenuator fixed, adjust laser power
(monet set) range # show the accessible power range
(monet set) power 50 # set 50 mW (open-loop, from calibration)
(monet set) feedback 50 # set 50 mW closed-loop against the powermeter
(monet set) measure # read the powermeter + calibration deviation
(monet set) status # show all lasers + accessible range
(monet set) exit
mode accepts combined (default), fixed_laser, or fixed_attenuator. feedback and the fixed modes require a multi-power calibration (≥ 2 calibrated laser power levels). PI gains and tolerance for feedback are tunable via feedback_config --kp: 0.9 --ki: 0.1 --tol: 1.0 --max_iter: 20.
Start the centralized database server on one lab machine. serve binds
127.0.0.1 by default; to reach it from other machines you must expose it on the
network and configure authentication (see Authentication
below) — the server will refuse a non-loopback bind without tokens:
# tokens configured -> may bind the network interface
export PAINT_MONET_TOKENS="s3cr3t-write:write:microscope-mercury,s3cr3t-read:read:dashboards"
python -m monet serve --db-path /shared/calibrations.db --host 0.0.0.0 --port 8000Then configure each microscope's YAML config to point at the server:
database: http://server-hostname:8000All calibrate, set, adjust, and gui commands work unchanged — monet automatically routes through HTTP when the database path is a URL.
Passing a microscope name to serve additionally exposes a target-power API
so an external recommender (PycroFlow) can set a per-laser power and log the
measured value:
python -m monet serve <MicroscopeName> --host 127.0.0.1 --port 8000POST /power/set— set a per-laser target power (writescope). Reuses the closed-loop PI setter when a power meter is attached, else sets open-loop from the calibration; returns the measured power. The request is clamped to a hard per-laser safety ceiling before the laser is actuated (see below).GET /power— read back the current measured/predicted power (readscope).POST /laser/set{laser, enabled}— enable/disable one laser's emission (write).POST /laser/offdisables all lasers — the fail-safe the recommender/PycroFlow calls on end-of-run and abort (A10/C21).GET /laserreports per-laser state (read).
Without a microscope name, serve runs the database only and the /power and
/laser routes return 503.
Safety ceiling (C34). A monet write actuates laser hardware, so power-set
is bounded by a hard per-laser maximum. Until the versioned site descriptor
(WP-FLEET) supplies it, configure the ceiling in the microscope YAML:
safety:
max_power_mw:
488: 120.0
561: 200.0
640: 200.0A request above the ceiling is clamped down (the response flags clamped: true
and reports the delivered target_power_mw); the hardware is never driven above
the ceiling. Enforcement is in code, not advisory.
The serve API (calibration DB and the power actuator) uses the shared
bearer-token auth helper from picasso-registry (picasso_registry.auth, WP-3b /
ADR-001),
so both DNA-PAINT services share one audited implementation.
- Tokens live in
PAINT_MONET_TOKENS(never committed, never in the DB) as a comma/semicolon/newline-separatedtoken:scope:labelmap.scopeisreadorwrite;labelis the attributable holder (e.g.microscope-mercury,cluster). Awritetoken also satisfiesread. - Scopes:
writeon DB edits (/calibrations,/factors,/calibrations/delete,/database/restart) and onPOST /power/set;readon the query routes andGET /power./healthis public. - monet's own clients (a microscope running
calibrate/set, or the GUI, pointingdatabase:at an auth-enabled server) read their token fromPAINT_MONET_TOKEN(a single value, not the server's map). Because the client both reads and writes the DB, give it awritetoken. Unset ⇒ no header sent (works against a loopback / auth-off server). Enable auth on a networked server and setPAINT_MONET_TOKENon the clients together — a token-enforcing server rejects token-less clients with 401. - Toggle (
PAINT_MONET_AUTH) for easy onboarding —off|on|auto(defaultauto):autoenforces iff tokens are set (backward-compatible);offruns open on loopback and makes the client omit its token (debugging);onrequires tokens and makesmonet serverefuse to start without them. Set it in.envalongside the token. - Fail-closed: with auth inactive the service refuses any non-loopback bind (startup guard) and any non-loopback request (request-time net). Loopback dev stays zero-config.
- TLS: terminate TLS at a reverse proxy (Caddy/nginx) or run uvicorn with
--ssl-keyfile/--ssl-certfile. Do not expose plain HTTP off-box. - Dashboard: the browser dashboard (
/dashboard/) is bearer-guarded — when auth is enabled it shows a login prompt, and you paste a token (areadtoken views; awritetoken also deletes). The token is kept in the browser'slocalStorageand sent asAuthorization: Beareron each request; sign out clears it. You may still add a reverse-proxy layer (HTTP Basic / lab SSO) in front, per ADR-001.
The easy way — monet token (run on the server box, where the .env lives).
It generates the token, writes the PAINT_MONET_TOKENS map for you, and prints
the value once with the exact line to paste on the client:
monet token add --scope write --label microscope-mercury # -> prints PAINT_MONET_TOKEN=…
monet token add --scope read --label dashboards
monet token list # scopes + labels only (never the values)
monet token rotate --label microscope-mercury
monet token revoke --label microscope-mercury
# --env-file PATH targets a specific .env (default: the package-root .env)Tokens are read at startup. To apply a change, restart serve — or, on Unix,
kill -HUP <serve-pid> live-reloads the tokens with no downtime.
systemd
EnvironmentFiledeployments:monet tokenand the SIGHUP reload both target a.env, not a systemdEnvironmentFile. If your unit sets tokens viaEnvironmentFile=/etc/monet/monet.env, pass--env-file /etc/monet/monet.envtomonet tokenandsystemctl restartthe unit to apply — editing the package-root.env(the default) would be silently ignored by such a server.
Give each machine/role its own label so it can be rotated/revoked
independently; the
label is what attributes writes in the logs.
Verify from a client — monet auth test. On a rig whose .env has
PAINT_MONET_TOKEN, check the whole chain (reachability + auth) and see the
label/scope the server knows the token by:
monet auth test --url http://<server>:8000 # or: monet auth test <MicroscopeName>
# -> Authenticated: YES — 'microscope-mercury' (scope: write)Exit code is 0 when the server accepts the token (or auth is off), 1 otherwise.
By hand (equivalent): a token is just a high-entropy string that must not
contain : , ; or a newline (the map's separators):
python -c "import secrets; print(secrets.token_urlsafe(32))" # or: openssl rand -hex 32
# then add one `token:scope:label` entry per holder, comma-separated, to
# PAINT_MONET_TOKENS (see below).Store it out of the repo and off the DB: a per-machine, root-owned
EnvironmentFile is the simplest fit for the systemd unit that runs serve:
# /etc/monet/monet.env (chmod 600, owned by the service user; NEVER committed)
PAINT_MONET_TOKENS=Xy7...q:write:microscope-mercury,Zq9...t:read:dashboards
MONET_DB_PATH=/shared/calibrations.db# /etc/systemd/system/monet-serve.service
[Service]
EnvironmentFile=/etc/monet/monet.env
ExecStart=/opt/monet/.venv/bin/monet serve Mercury --host 0.0.0.0 --port 8000A gitignored .env (loaded by your shell/direnv) or a secrets manager works
too; the only hard rules are never in git and never in the calibration DB.
Rotate / revoke with monet token rotate/revoke --label <holder> (or edit the
map by hand), then restart serve. Because each holder has a distinct label,
you can rotate/revoke one microscope or the dashboards without disturbing the
others. There is no online revocation list — a change applies on restart, so keep
the map small and per-role.
# clients send the bearer token
curl -H "Authorization: Bearer $TOKEN" \
-X POST http://scope:8000/power/set \
-d '{"laser": 488, "target_power_mw": 30}'Testing & rollout: to validate server–client auth before touching production
(how to generate <wtok>/<rtok>, a hardware-free harness, a two-machine dry run
against a copy of the prod DB, and the safe cutover ordering), see
docs/staging/ROLLOUT.md.
| Mode | Command | Description |
|---|---|---|
| Calibrate | python -m monet calibrate <Name> |
Run power calibration protocol |
| AOTF Calibrate | python -m monet caliaotf <Name> |
Calibrate AOTF frequency and power |
| Adjust | python -m monet adjust <Name> |
Interactive laser alignment and adjustment |
| Set | python -m monet set <Name> |
Set laser power from existing calibration |
| GUI | python -m monet gui <Name> |
Launch the PyQt6 graphical interface |
| Serve | python -m monet serve |
Start the database server |
| Migrate | python -m monet migrate --source <xlsx> --db-path <db> |
Migrate Excel database to SQLite |
| Token | python -m monet token add --scope write --label <holder> |
Manage server auth tokens (add/list/revoke/rotate) |
# loopback by default; add a microscope name to enable the power API
python -m monet serve <MicroscopeName> --host 127.0.0.1 --port 8000 --db-path calibrations.dbBinding a non-loopback --host requires PAINT_MONET_TOKENS
(see Authentication); otherwise the server refuses to start.
If you have an existing Excel calibration database, migrate it to SQLite:
python -m monet migrate --source power_database.xlsx --db-path calibrations.dbThis preserves all historical calibration dates and times.
python -m monet gui <MicroscopeName> opens a PyQt6 window with four tabs:
- Set Power — closed-loop feedback control with live convergence plot, accessible-range readout, and direct attenuator / laser power controls.
- Calibrate — run 1D or 2D calibration protocols with progress and cancel.
- Database — browse calibration records and compute objective-transmission factors.
- Adjust — direct alignment controls (also reachable from inside Set Power).
The full GUI (or any individual tab) can be embedded in any host PyQt6 application via the monet.qt module:
from monet.qt import MonetWidget, SetPowerTab
# Pattern A — drop the whole 4-tab interface into a host window
widget = MonetWidget(show_toolbar=False) # hide the microscope picker
widget.set_pc(my_calibration_protocol) # inject an externally-built pc
widget.status_changed.connect(host.statusBar().showMessage)
host_layout.addWidget(widget)
# Pattern B — drop just one tab next to your own widgets
tab = SetPowerTab()
tab.set_pc(my_calibration_protocol)
tab.status.connect(host.statusBar().showMessage)
host_tabs.addTab(tab, 'Laser')MonetWidget exposes:
set_microscope(name),connect_microscope(name=None),set_pc(pc),shutdown()- Signals:
status_changed(str, int),connected(object),connect_error(str),calibration_started(),calibration_finished() tab('set_power' | 'calibrate' | 'database' | 'adjust')for direct access to an embedded tab- Construction options:
show_toolbar,tabs=(...),initial_microscope
A runnable end-to-end demo is in examples/embed_monet.py.
When running in server mode, monet exposes these HTTP endpoints:
| Endpoint | Method | Scope | Description |
|---|---|---|---|
/calibrations |
POST | write |
Save a new calibration record |
/calibrations/query |
POST | read |
Query calibration records (supports filtering and time modes) |
/calibrations/delete |
POST | write |
Delete calibration records matching a query |
/database/restart |
POST | write |
Backup current database and prune to latest entries |
/factors |
POST | write |
Save/update an objective-transmission factor |
/factors/query |
POST | read |
Query objective-transmission factors |
/power/set |
POST | write |
Set a per-laser target power (clamped to the safety ceiling); returns measured power. Requires serve <Name> |
/power |
GET | read |
Read back measured/predicted power. Requires serve <Name> |
/laser/set |
POST | write |
Enable/disable one laser's emission. Requires serve <Name> |
/laser/off |
POST | write |
Disable ALL lasers (fail-safe, A10/C21). Requires serve <Name> |
/laser |
GET | read |
Per-laser enabled state + current laser. Requires serve <Name> |
/dashboard/ |
GET | public | Browser dashboard HTML shell (public so the login UI loads) |
/dashboard/api/* |
GET/POST | read |
Dashboard data (bearer token via the page's login) |
/auth/whoami |
GET | read |
Report the caller's token (label, scope) — used by monet auth test and the dashboard login |
/health |
GET | public | Health check |
Scopes are enforced only when PAINT_MONET_TOKENS is set; see Authentication.
monet reads its per-machine settings from environment variables, loaded from a
gitignored .env in the package root at import (via python-dotenv,
override=False — an already-exported variable or a systemd EnvironmentFile
still wins). Copy .env.template to .env and fill in:
| Variable | Purpose |
|---|---|
MONET_CONFIG_PATHS |
os.pathsep-separated list of configs.yaml paths (first that exists wins) |
MONET_PROTOCOL_PATHS |
same, for protocols.yaml |
PAINT_MONET_AUTH |
auth toggle: off | on | auto (default auto) |
PAINT_MONET_TOKEN |
client bearer token (a write token; see Authentication) |
PAINT_MONET_TOKENS |
server token→scope→label map (on the serve host) |
Deprecation: the config/protocol paths used to live in
env.yaml(config_paths/protocol_paths). That file is still read as a fallback but emits aDeprecationWarning; migrate toMONET_CONFIG_PATHS/MONET_PROTOCOL_PATHSin.env.
Microscope configurations are defined in YAML files referenced by MONET_CONFIG_PATHS (or, deprecated, env.yaml). Each config specifies:
database— file path (.xlsx) or server URL (http://...)index— microscope name, wavelength, laser powerpowermeter— classpath and init kwargsattenuation— classpath and init kwargsanalysis— classpath and init kwargs (curve fitting parameters)lasers— per-wavelength laser definitions (for 2D protocols)beampath— filter wheel and shutter definitions
See monet/__init__.py for example configurations.
| Kind | Classes |
|---|---|
Lasers (monet.laser) |
TestLaser, MPBVFL, Toptica, Cobolt, Cobolt_OEM, LaserQuantum |
Attenuators (monet.attenuation) |
TestAttenuator, KinesisAttenuator, AAAOTFAttenuator, NIdaqmxAOAttenuator |
Power meters (monet.powermeter) |
TestPowerMeter, ThorlabsPowerMeter |
Beam-path devices (monet.beampath) |
TestShutter, NikonShutter, NikonFilterWheel, NikonNosepiece |
Test* classes are simulation-only — no hardware or SDK required.
monet/
├── __init__.py # Configuration loading, constants
├── __main__.py # CLI entry point (calibrate, set, adjust, gui, serve, migrate)
├── calibrate.py # CalibrationProtocol1D / 2D
├── analysis.py # Curve fitting (sinusoidal, linear, polynomial, point)
├── control.py # IlluminationControl / IlluminationLaserControl + run_power_feedback
├── io.py # Database I/O with Excel/HTTP dispatch
├── cache.py # Local SQLite mirror + offline outbox for HTTP mode
├── server.py # FastAPI database server
├── dashboard.py # /dashboard HTML view served by the FastAPI app
├── models.py # SQLAlchemy models
├── schemas.py # Pydantic request/response schemas
├── migrate.py # Excel → SQLite migration
├── laser.py # Laser drivers (Toptica, MPBVFL, Cobolt, LaserQuantum, Test)
├── attenuation.py # Attenuator drivers (Kinesis, AAAOTF, NIdaqmxAO, Test)
├── powermeter.py # Power-meter drivers (Thorlabs, Test)
├── beampath.py # Beam path control (filter wheels, shutters)
├── aotf_cali.py # AOTF frequency/power calibration
├── gui.py # PyQt6 widget + MonetWidget container + MonetMainWindow
├── qt.py # Public Qt-level API for embedding
├── util.py # Dynamic class loading + MicroManager comment writer
└── tests/ # pytest suite (uses Test* hardware)
# Run all tests
pytest
# Run a specific test file
pytest monet/tests/test_server.py -v
# Run with coverage report
pytest --cov=monetTests use unittest.TestCase with pytest as the runner. Hardware is simulated via TestPowerMeter, TestAttenuator, and TestLaser classes. Server tests use FastAPI's TestClient for in-process testing without a running server. The GUI smoke test (test_gui_widget.py) auto-skips when PyQt6 is not installed.
BSD 2-Clause. See LICENSE.md for details.