Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
# project-wise generated files
run/
database/
/database
database_archive
database_archive/

# Third-party equipment manuals -- vendor-copyrighted, not redistributable here.
pascal_manuals/
pascal/storage
pascal/data
old/pascal/data
Expand Down
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Haotong Liang and the Lumi-Lab contributors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
53 changes: 39 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
# Lumi-Lab

`lumi` — the contract-driven control stack for the PLD lab. Each piece of equipment runs
as a **node** (a process in `nodes/`) that talks to the others over RabbitMQ. What a node
can do is declared once in `src/lumi/contracts/`, and both the Python clients and the
frontend's TypeScript client are generated from that declaration.
`lumi` — the contract-driven control stack for a pulsed-laser-deposition (PLD) lab. Each
piece of equipment runs as a **node** (a process in `nodes/`) that talks to the others
over RabbitMQ. What a node can do is declared once in `src/lumi/contracts/`, and both the
Python clients and the frontend's TypeScript client are generated from that declaration.

This is the platform behind
[*Autonomous epitaxial atomic-layer synthesis via real-time computer vision of electron
diffraction*](https://arxiv.org/abs/2602.20432): the growth driver, chamber and RHEED
nodes, and the closed-loop Bayesian-optimisation notebook here are the ones that ran the
campaigns in that paper. See [Citation](#citation) below.

Nodes:

Expand Down Expand Up @@ -316,20 +322,18 @@ host that really does talk to the chamber, set `rabbitmq.host` in your own
`cfg/settings.example.toml`. The broker address is a machine-local fact, and having it in
the shared template is what made `python -m lumi.mcp` reach for the lab by default.

> **The lab broker is not ready for this yet.** It still holds the pre-refactor
> messaging layer's exchanges — `CHAMBER`, `RHEED` and `STORAGE` exist there as
> non-durable **`direct`** exchanges, with live bindings from the old-style nodes. Any
> contract-era client that declares them as `topic` is refused at startup:
> **Point it at a broker only this stack uses.** Every node declares its exchanges as
> durable `topic` exchanges at startup. If the broker already carries exchanges of the
> same name with different settings — e.g. non-durable `direct` ones left by another
> messaging layer — RabbitMQ refuses the declaration:
>
> ```
> PRECONDITION_FAILED - inequivalent arg 'type' for exchange 'RHEED' in vhost '/':
> received 'topic' but current is 'direct'
> ```
>
> This is not specific to the MCP server — it will happen to any refactored node pointed
> at that broker. Clearing it means deleting those three exchanges (they are non-durable,
> so a broker restart drops them anyway) once the old nodes are no longer using them.
> Until then, use a local broker.
> Use a dedicated vhost or a fresh broker, and delete any conflicting exchanges first
> (non-durable ones are dropped by a broker restart anyway).

The tools are generated, one per `(contract, capability, op)`, named
`experiment.driver.to_temperature` and so on — 49 of them today. Adding an op to
Expand Down Expand Up @@ -403,8 +407,8 @@ travel limits). It runs entirely in-process with simulated time, so it's fast.

## Notebooks

`notebooks/` holds the two operator notebooks, ported from the v1.0 ones and written to
run against the simulator as-is:
`notebooks/` holds the two operator notebooks, written to run against the simulator
as-is:

| notebook | what it does |
| --- | --- |
Expand Down Expand Up @@ -439,3 +443,24 @@ uv run lumi-codegen --check # CI gate: fails if anything on disk has drifte
The contract hash printed by those commands gates backend/frontend compatibility — if it
changes, the frontend needs the regenerated `lumi.ts`. See `docs/FRONTEND_MIGRATION.md`
for how the browser-side API maps onto the bridge.

## Citation

If this software is useful in your research, please cite:

```bibtex
@article{liang2026autonomous,
title = {Autonomous epitaxial atomic-layer synthesis via real-time computer
vision of electron diffraction},
author = {Liang, Haotong and Sun, Yunlong and Paxson, Ryan and Lee, Chih-Yu and
Hall, Alex T. and Warecki, Zoey and Cumings, John and Koinuma, Hideomi and
Kusne, Aaron Gilad and Lippmaa, Mikk and Takeuchi, Ichiro},
journal = {arXiv preprint arXiv:2602.20432},
year = {2026},
url = {https://arxiv.org/abs/2602.20432}
}
```

## License

[MIT](LICENSE) © 2026 Haotong Liang and the Lumi-Lab contributors.
81 changes: 33 additions & 48 deletions notebooks/BODeposition.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,13 @@
"# BO Deposition\n",
"\n",
"Closed-loop Bayesian optimisation over growth conditions: propose → grow →\n",
"measure → refit, one substrate position per iteration. Ported from\n",
"`UMDAutonomousExperiment.ipynb`.\n",
"measure → refit, one substrate position per iteration. This is the loop from\n",
"[arXiv:2602.20432](https://arxiv.org/abs/2602.20432) -- the GP, the UCB /\n",
"max-uncertainty acquisition functions and the convergence check live in `lumi.opt`.\n",
"\n",
"The loop itself is unchanged -- same GP, same UCB/max-uncertainty acquisition\n",
"functions, same convergence check, ported in `lumi.opt` from the code that ran the real\n",
"campaigns. What changed is where the campaign's memory lives:\n",
"\n",
"| v1.0 | now |\n",
"| --- | --- |\n",
"| `GPManager` + `gp_db/<project>.csv` | `GrowthCampaign`, reading `list_measurements` |\n",
"| `collector` pickle, `local_experiment_counter` | `list_samples` -- positions are rows |\n",
"| `substrate.has_lsmo` monkeypatched on | the derived layer stack |\n",
"| metric kept beside the notebook | a `measurement` row on the sample |\n",
"| `PixelExperimentManager.perform_experiment` | `recipes.perform_pixel_deposition` |\n",
"\n",
"That the training set comes from the growth database and not a local CSV is the\n",
"substantive change: a campaign can now be resumed from a different machine, and the GP\n",
"is fitted on what the lab actually recorded.\n",
"The campaign's memory is the growth database, not a local CSV: `GrowthCampaign` reads\n",
"its training set back through `list_measurements` each iteration, so a campaign can be\n",
"resumed from a different machine and the GP is fitted on what the lab actually recorded.\n",
"\n",
"**Runs against the simulator as written:**\n",
"\n",
Expand Down Expand Up @@ -81,7 +70,7 @@
"metadata": {},
"source": [
"HOST = \"localhost\"\n",
"ACTOR = \"hliang16\"\n",
"ACTOR = \"operator\"\n",
"DRYRUN = True # True: the loop runs, the laser does not\n",
"\n",
"exp = await ExperimentSession.open(host=HOST, actor=ACTOR)\n",
Expand Down Expand Up @@ -109,7 +98,7 @@
"cell_type": "code",
"metadata": {},
"source": [
"PROJECT = \"UMD_AI_HZO_BO\"\n",
"PROJECT = \"hzo_bo_demo\"\n",
"TARGET_SLOT = \"D\" # the functional layer's carousel slot\n",
"N_RANDOM = 2 # random seed points before the GP takes over\n",
"NUM_PULSE = 120 # real: 500 (~9 nm of HZO)\n",
Expand All @@ -135,10 +124,10 @@
"source": [
"## The optimiser\n",
"\n",
"Two acquisition functions, exactly as the production campaign ran them: UCB with\n",
"`beta=3` to exploit, and maximum-uncertainty to break out when UCB converges on a local\n",
"maximum. The constant mean at 0.5 and the length-scale interval are the values those\n",
"runs used -- a shorter length scale under-fits and yields no useful uncertainty.\n"
"Two acquisition functions: UCB with `beta=3` to exploit, and maximum-uncertainty to\n",
"break out when UCB converges on a local maximum. The constant mean at 0.5 and the\n",
"length-scale interval are the values the published campaigns used -- a shorter length\n",
"scale under-fits and yields no useful uncertainty.\n"
]
},
{
Expand Down Expand Up @@ -183,8 +172,8 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"A band of one axis can be excluded -- the production campaign ruled out a pressure\n",
"window the chamber could not hold stably.\n"
"A band of one axis can be excluded -- e.g. a pressure window the chamber cannot hold\n",
"stably.\n"
]
},
{
Expand Down Expand Up @@ -288,9 +277,8 @@
" \"\"\"Block until the chamber's motors report free.\n",
"\n",
" `to_current_pixel` moves the mask and the RHEED gun, and refuses outright while a\n",
" previous move is still running. The production notebook covered this with a manual\n",
" \"check motor free\" gate before each growth; an unattended loop has to wait for it\n",
" itself.\n",
" previous move is still running. An unattended loop has to wait for it rather than\n",
" relying on a person to check \"motor free\" before each growth.\n",
" \"\"\"\n",
" deadline = asyncio.get_running_loop().time() + timeout\n",
" while asyncio.get_running_loop().time() < deadline:\n",
Expand Down Expand Up @@ -319,32 +307,30 @@
"source": [
"## Scoring a growth\n",
"\n",
"In the lab this is `analyze_rheed_video` from the RHEED analysis stack, which needs the\n",
"detection host's `rhana`/`mmdet` install and a real recorded HDF5. It returns growth,\n",
"speed, quality and roughness components plus the combined `metric`.\n",
"In the lab this is a RHEED-video analysis that reads the recorded HDF5 and returns\n",
"growth, speed, quality and roughness components plus the combined `metric`. It needs the\n",
"detection host's model stack (`rhana` / `mmdet`) and a real recording.\n",
"\n",
"Against the simulator there is no film to score, so the fallback below stands in a\n",
"smooth synthetic landscape -- enough to watch the loop converge on something. Swap\n",
"`score_growth` for the real analysis on the detection host.\n"
"smooth synthetic landscape -- enough to watch the loop converge on something. Point\n",
"`score_growth` at the real analysis on the detection host.\n"
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"try:\n",
" from src.analysis import analyze_rheed_video # the lab's RHEED analysis stack\n",
" HAVE_ANALYSIS = True\n",
"except ImportError:\n",
" HAVE_ANALYSIS = False\n",
"# Set this True on the detection host, where the RHEED-video analysis is importable,\n",
"# and fill in the call below.\n",
"HAVE_ANALYSIS = False\n",
"\n",
"print(\"real RHEED analysis available:\", HAVE_ANALYSIS)\n",
"\n",
"\n",
"async def score_growth(storage_name: str, conditions: pd.Series) -> dict:\n",
" \"\"\"Return the metric dict for a finished growth.\n",
"\n",
" Replace the fallback with analyze_rheed_video(...) on the detection host:\n",
" On the detection host, replace the fallback with the real analysis, e.g.:\n",
"\n",
" metrics, _ = await to_async(\n",
" analyze_rheed_video,\n",
Expand All @@ -355,8 +341,8 @@
" \"\"\"\n",
" if HAVE_ANALYSIS:\n",
" raise NotImplementedError(\n",
" \"wire analyze_rheed_video in here -- it needs the recorded HDF5 and the \"\n",
" \"crop/periodicity setup from the production notebook\"\n",
" \"wire the RHEED-video analysis in here -- it needs the recorded HDF5 and \"\n",
" \"the crop / periodicity setup for your chamber\"\n",
" )\n",
"\n",
" # Synthetic stand-in: a smooth optimum inside the search box, plus noise.\n",
Expand All @@ -379,11 +365,11 @@
"Propose &rarr; grow &rarr; score &rarr; record &rarr; refit, until the substrate runs\n",
"out of positions or the GP converges.\n",
"\n",
"Two differences from the v1.0 loop worth pointing at. `campaign.refresh(exp.driver)`\n",
"reloads the training set from the growth database each iteration, so the GP sees every\n",
"measurement the lab has -- including ones added from another machine, or from a\n",
"re-analysis. And there is no `collector` to keep in step: the position that was grown,\n",
"what went on it and what it scored are all rows the system wrote itself.\n"
"One thing worth pointing at: `campaign.refresh(exp.driver)` reloads the training set\n",
"from the growth database each iteration, so the GP sees every measurement the lab has --\n",
"including ones added from another machine, or from a re-analysis. There is no separate\n",
"bookkeeping to keep in step: the position that was grown, what went on it and what it\n",
"scored are all rows the system wrote itself.\n"
]
},
{
Expand All @@ -407,8 +393,7 @@
" print(f\"\\nconverged after {iteration} growths -- stopping\")\n",
" break\n",
" if is_converged:\n",
" # A local maximum: one round of pure exploration to break out. Same\n",
" # escape the production campaign used.\n",
" # A local maximum: one round of pure exploration to break out.\n",
" conditions = campaign.propose(\"exploration\")\n",
" mode = \"exploration (escaping a local maximum)\"\n",
" else:\n",
Expand Down
30 changes: 16 additions & 14 deletions notebooks/README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
# Notebooks

Ported from the v1.0 notebooks in `~/HZO_PLD/OpCode/`, onto the current contract-driven
API. Both are written **sim-first**: they run end to end against
Two operator notebooks, written **sim-first**: they run end to end against
`scripts/start_simulation.sh` as written, with the real-hardware numbers kept in one
clearly marked config cell each.

| notebook | what it does | ported from |
| --- | --- | --- |
| `SingleDeposition.ipynb` | a layered growth on one position — bottom electrode → interface → functional layer | `UMDSingleDepoExperiment.ipynb` |
| `BODeposition.ipynb` | closed-loop Bayesian optimisation over growth conditions, one position per iteration | `UMDAutonomousExperiment.ipynb` |
| notebook | what it does |
| --- | --- |
| `SingleDeposition.ipynb` | a layered growth on one position — bottom electrode → interface → functional layer |
| `BODeposition.ipynb` | closed-loop Bayesian optimisation over growth conditions, one position per iteration |

`BODeposition.ipynb` runs the loop from
[arXiv:2602.20432](https://arxiv.org/abs/2602.20432).

## Running them

Expand Down Expand Up @@ -36,8 +38,8 @@ uv sync --extra opt # torch, gpytorch
```

To switch either notebook to real hardware, edit `HOST`, set `DRYRUN = False`, and
replace the values in the marked parameters cell — the production numbers are in the
comment on each line.
replace the values in the marked parameters cell — representative real numbers are in
the comment on each line.

## Editing them

Expand Down Expand Up @@ -66,14 +68,14 @@ layers grown, every step journaled with its actor, the derived layer stack read
`BODeposition.ipynb` has also been executed end to end: 2 random-seed growths then 5
GP-proposed ones, all positions on the substrate used, no exception. That confirms the
loop's mechanics (bookkeeping, gates, journaling, GP wiring) but **not** its
optimisation behaviour under `DRYRUN = True` -- `to_temperature` is skipped outright in
optimisation behaviour under `DRYRUN = True` `to_temperature` is skipped outright in
dryrun and pressure is always a printed manual instruction with nothing to act on it in
the simulator, so the conditions the GP trains on don't match what was proposed. See
`docs/SAMPLE_TRACKING.md` for the detail and the remaining to-do.

## `analyze_rheed_video`
## Scoring a growth

The BO notebook's `score_growth` is where the real RHEED analysis goes. It needs the
detection host's `rhana`/`mmdet` install and a recorded HDF5, neither of which the
simulator has, so against the simulator it falls back to a synthetic landscape and
says so in its output. Swap it for `analyze_rheed_video(...)` on the detection host.
The BO notebook's `score_growth` is where the real RHEED-video analysis goes. It needs
the detection host's `rhana`/`mmdet` install and a recorded HDF5, neither of which the
simulator has, so against the simulator it falls back to a synthetic landscape and says
so in its output. Point it at the real analysis on the detection host.
Loading
Loading