Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
53c450c
Merge pull request #157 from pycroscopy/gerd-dev
AustinHouston Aug 7, 2026
0bb40dd
add(feat):segment.py
Darel-Pates Aug 7, 2026
43ea51b
Rework startup GUIs (theming, checkmarks, tool badge, host toggle) an…
whittlegears Aug 7, 2026
5c35fe3
Restore ProcessManager.save() call on process start
whittlegears Aug 7, 2026
023a6e6
Merge pull request #166 from pycroscopy/main
AustinHouston Aug 8, 2026
c7b75ad
Merge branch 'main' of https://github.com/Darel-Pates/asyncroscopy in…
AustinHouston Aug 8, 2026
ed7ce86
working on segmentation
AustinHouston Aug 10, 2026
8136fa3
Merge pull request #168 from pycroscopy/dev-codex
AustinHouston Aug 10, 2026
abad0de
Fix: windows deployment
whittlegears Aug 10, 2026
ff77a76
Feat: GUI updated and using process manager
whittlegears Aug 10, 2026
99ce8de
Merge branch 'main' of https://github.com/whittlegears/asyncroscopy
whittlegears Aug 10, 2026
b0c77be
4DSTEM
AITEM-dev Aug 10, 2026
06f2ee2
scifireaders dependency for 4DSTEM
AustinHouston Aug 10, 2026
40b7454
feat: 4DSTEM acquisition
AustinHouston Aug 10, 2026
ff8793e
tests(4DSTEM)
AustinHouston Aug 10, 2026
e023075
testing with notebook
AustinHouston Aug 10, 2026
8a1892f
checking params
AITEM-dev Aug 10, 2026
fd0c2cb
Update MCP server, GUI, and startup scripts for testing
whittlegears Aug 10, 2026
b43d835
fix: readable failures and honest tool metadata for the MCP-driven ac…
whittlegears Aug 10, 2026
c143d3d
Fix: route GUI Stop button and startup scripts through ProcessManager
whittlegears Aug 10, 2026
5cd59c1
Merge pull request #11 from whittlegears/fixes
whittlegears Aug 10, 2026
354caec
fix(4DSTEM)
AustinHouston Aug 11, 2026
5138595
run segmentation server
AustinHouston Aug 11, 2026
5d3a0d6
extra dependency fix
AustinHouston Aug 11, 2026
6933864
segment on cuda
AustinHouston Aug 11, 2026
d893721
debugging cuda
AustinHouston Aug 11, 2026
f7c6e2a
write data to remote with tiled
AustinHouston Aug 11, 2026
bd30988
feat(Segmentation server): finally working
AustinHouston Aug 11, 2026
cec8adb
remove unsupported camera setting and refresh notebook
AustinHouston Aug 11, 2026
54e2686
expand and thin polycrystalline gold volume
AustinHouston Aug 11, 2026
0b64dd1
update and rename particle digital twin
AustinHouston Aug 11, 2026
8e28ab2
DT for aberration corrector
AustinHouston Aug 12, 2026
9c6e154
Merge pull request #176 from pycroscopy/dev-ACH
gduscher Aug 13, 2026
9e5c737
feat: testing aberrations twin
utkarshp1161 Aug 14, 2026
8d7b2e1
Merge pull request #178 from pycroscopy/dev-up
utkarshp1161 Aug 17, 2026
1f145e3
fix: 30s Tango timeout on bridge proxies and inline PNG previews for …
whittlegears Aug 21, 2026
57b540c
Merge origin/main, keeping the initial-testing tree as tested
whittlegears Aug 21, 2026
1c32548
setting up tango code for opentrons device
Sep 11, 2026
1a3a8d6
update: AS_1.18 wheels
utkarshp1161 Sep 15, 2026
2b1269d
update: AS_1.18 wheels
utkarshp1161 Sep 15, 2026
c095e67
Merge pull request #181 from pycroscopy/dev-up
utkarshp1161 Sep 16, 2026
cd5052b
adding folder to main
lcoope45 Sep 16, 2026
f46cfc5
Merge branch 'main' into pipetting_robot
lcoope45 Sep 16, 2026
06937bd
Merge pull request #182 from pycroscopy/pipetting_robot
lcoope45 Sep 18, 2026
4c61c6f
Merge pull request #170 from whittlegears/main
gduscher Sep 18, 2026
2d9603e
Merge branch 'boris_instrument' into main
utkarshp1161 Sep 25, 2026
18062b5
add: mcp server test notebook
utkarshp1161 Oct 2, 2026
99febf1
Merge pull request #185 from pycroscopy/mcp-server
utkarshp1161 Oct 5, 2026
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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,28 @@ Start the MCP server in a second terminal for agent/AI integration:
uv run startup_scripts/run_mcp.py --yaml configs/mcp.yaml
```

Start only the segmentation Tango device against an existing Tango/DATA/Tiled stack:
```bash
uv run --extra segment startup_scripts/run_segmentation.py --yaml configs/Segmentation.yaml
```

Start the oriented-particle digital twin:

```bash
uv run --extra diffraction python startup_scripts/run_servers.py --yaml configs/digital_twin_particles.yaml
```

`configs/Segmentation.yaml` sets `compute_device: cuda`. On Linux and Windows,
the `segment` extra installs PyTorch from its CUDA 13.0 package index; macOS
continues to use the normal PyPI build. The launcher fails at startup instead
of silently using the CPU when CUDA is unavailable. Install and verify on the
GPU host before launching:

```bash
uv sync --extra segment --reinstall-package torch --reinstall-package torchvision
uv run --extra segment python -c "import torch; print(torch.__version__, torch.version.cuda, torch.cuda.is_available(), torch.cuda.get_device_name() if torch.cuda.is_available() else '')"
```

For interactive GUI-based startup:
```bash
uv run startup_guis/server_gui.py
Expand Down
68 changes: 66 additions & 2 deletions asyncroscopy/data/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path, PureWindowsPath
from urllib.error import URLError
from urllib.request import urlopen

import h5py
from SciFiReaders import MRCReader
from tango import AttrWriteType, DevState
from tango.server import Device, attribute, command
from tiled.client import from_uri
Expand All @@ -52,7 +55,7 @@ def init_device(self) -> None:
self.set_state(DevState.ON)
self._host, self._port = self._parse_uri(os.environ.get("ASYNCROSCOPY_TILED_URI", DEFAULT_TILED_URI))
self._save_path = os.environ.get("ASYNCROSCOPY_ACQUISITION_DIR", DEFAULT_ACQUISITION_DIR)
self._api_key = "secret"
self._api_key = os.environ.get("ASYNCROSCOPY_TILED_API_KEY", "secret")
self._tiled_process = None
self._tiled_serve_path = None
self._tiled_server = "yes" if self._tiled_alive() else "no"
Expand Down Expand Up @@ -152,7 +155,8 @@ def start_tiled_server(self, timeout=30) -> str:

command = [
*self._tiled_command(), "serve", "catalog", catalog_database,
"--read", self._save_path, "--public", "--api-key", self._api_key,
"--read", self._save_path, "--write", self._save_path,
"--public", "--api-key", self._api_key,
"--host", self._host, "--port", str(self._port),
]
self._tiled_process = subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT, text=True)
Expand Down Expand Up @@ -211,6 +215,66 @@ async def register_with_tiled_client() -> None:
self._tiled_server_status = "running; registered path"
return key

@command(dtype_in=str, dtype_out=str)
def copy_and_register_remote_file(self, request_json: str) -> str:
"""Convert a remote AutoScript MRC file to HDF5 and register it.

The source MRC is left untouched. The HDF5 file is written under the
configured data save path and becomes visible only after an atomic
rename, so Tiled can never observe a partial conversion.
"""
request = json.loads(request_json)
source = Path(request["source_path"]).expanduser()
if source.suffix.lower() not in {".mrc", ".mrcs"}:
raise ValueError(f"Expected an MRC source file, received: {source}")
if not source.is_file():
raise FileNotFoundError(f"Remote MRC file is not readable: {source}")

destination_directory = Path(self._save_path).expanduser()
destination_directory.mkdir(parents=True, exist_ok=True)
detector = str(request.get("detector", "BM-Ceta"))
stamp = datetime.now().strftime("%Y%m%dT%H%M%S%f")
destination = destination_directory / f"stem_data_{detector}_{stamp}.h5"
partial = destination.with_suffix(".h5.partial")

try:
channels = MRCReader(str(source)).read()
if "Channel_000" not in channels:
raise ValueError(f"SciFiReaders did not return Channel_000 for {source}")
source_data = channels["Channel_000"]
if len(source_data.shape) != 4:
raise ValueError(f"Expected SciFiReaders to return 4D-STEM data, received shape {source_data.shape}")

requested_scan_shape = tuple(int(value) for value in request.get("scan_shape", []))
if requested_scan_shape and tuple(source_data.shape[:2]) != requested_scan_shape:
raise ValueError(f"MRC scan shape {source_data.shape[:2]} does not match requested scan shape {requested_scan_shape}")

with h5py.File(partial, "w", track_order=True) as h5:
dataset = h5.create_dataset("stem_data", shape=source_data.shape, dtype=source_data.dtype, chunks=True)
for row in range(source_data.shape[0]):
for column in range(source_data.shape[1]):
frame = source_data[row, column]
dataset[row, column] = frame.compute() if hasattr(frame, "compute") else frame

dataset.attrs["acquisition_type"] = "stem_data"
dataset.attrs["detector"] = str(request.get("detector", "BM-Ceta"))
dataset.attrs["source_format"] = "MRC"
dataset.attrs["source_file"] = str(source)
dataset.attrs["data_type"] = str(getattr(source_data, "data_type", "image_4d"))
for name in ("dwell_time", "scan_region", "scan_shape"):
if name in request:
value = request[name]
dataset.attrs[name] = value if isinstance(value, (str, int, float, bool)) else json.dumps(value)

h5.attrs["source_mrc_metadata_json"] = json.dumps(getattr(source_data, "original_metadata", {}), default=lambda value: value.tolist() if hasattr(value, "tolist") else str(value))

os.replace(partial, destination)
except Exception:
partial.unlink(missing_ok=True)
raise

return self.register_path(str(destination))

@command(dtype_out=str)
def register_save_path(self) -> str:
"""Register the configured save directory with Tiled once."""
Expand Down
79 changes: 79 additions & 0 deletions asyncroscopy/data/data_reader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""Shared logic for reading Tiled dataset metadata and small previews.

Both the MCP bridge (``get_data_from_key``) and the electron microscope's
legacy byte-over-Tango command (``get_image_data_cached``) need the same
thing: given an already-resolved Tiled node, describe its shape/dtype/attrs
and a small flattened preview. This module is the one place that logic
lives; callers are responsible for resolving the Tiled client/node
themselves, since that involves a DeviceProxy in one case and an in-process
DeviceProxy in the other.
"""

from __future__ import annotations

from typing import Any

import numpy as np


def numpy_to_python(obj: Any) -> Any:
"""Recursively convert numpy types to Python types for JSON serialization."""
if isinstance(obj, np.ndarray):
return numpy_to_python(obj.tolist())
if isinstance(obj, np.generic):
return obj.item()
if isinstance(obj, dict):
return {k: numpy_to_python(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
conv = [numpy_to_python(v) for v in obj]
return tuple(conv) if isinstance(obj, tuple) else conv
return obj


def describe_tiled_node(key: str, uri: str, node: Any, max_values: int = 64) -> dict[str, Any]:
"""Build shape/dtype/attrs metadata plus a small flattened preview for one Tiled node."""
limit = max(0, int(max_values))
suffix = key.rsplit(".", 1)[-1].lower() if "." in key else "unknown"
result: dict[str, Any] = {
"key": key,
"uri": uri,
"format": "hdf5" if suffix in {"h5", "hdf5"} else suffix,
"attrs": numpy_to_python(dict(getattr(node, "metadata", {}) or {})),
}
datasets: list[dict[str, Any]] = []

def visit(current: Any, name: str = "") -> None:
read = getattr(current, "read", None)
if callable(read):
shape = tuple(getattr(current, "shape", ()) or ())
if limit == 0:
array = np.asarray([], dtype=getattr(current, "dtype", float))
elif shape:
remaining = limit
slices = []
for size in reversed(shape):
take = min(int(size), max(1, remaining))
slices.append(slice(0, take))
remaining = (remaining + take - 1) // take
array = np.asarray(read(tuple(reversed(slices))))
else:
array = np.asarray(read())
item: dict[str, Any] = {
"name": name,
"shape": list(shape or array.shape),
"dtype": str(getattr(current, "dtype", array.dtype)),
"attrs": numpy_to_python(dict(getattr(current, "metadata", {}) or {})),
"preview": numpy_to_python(array.reshape(-1)[:limit]),
}
datasets.append(item)
return

keys = getattr(current, "keys", None)
if callable(keys):
for child_name in keys():
child_path = f"{name}/{child_name}" if name else str(child_name)
visit(current[child_name], child_path)

visit(node)
result["datasets"] = datasets
return result
2 changes: 2 additions & 0 deletions asyncroscopy/data/data_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ def save_acquisition(
raise ValueError(f"Unsupported output_format {output_format!r}; expected '.h5' or '.tiff'")
detector_list = list(detectors) if isinstance(detectors, (list, tuple)) else [detectors]
data_list = list(data) if isinstance(data, (list, tuple)) else [data]
if not data_list:
raise ValueError("save_acquisition called with no data to save (empty detector/data list)")
attrs_list = dataset_attrs if isinstance(dataset_attrs, list) else [dataset_attrs] * len(data_list)

if output_format == ".tiff":
Expand Down
98 changes: 67 additions & 31 deletions asyncroscopy/instruments/electron_microscope/auto_script.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import json
import math
import time
from pathlib import Path

import numpy as np
import tango
Expand All @@ -35,14 +36,16 @@
import autoscript_tem_microscope_client
from autoscript_tem_microscope_client import TemMicroscopeClient
from autoscript_tem_microscope_client.enumerations import EdsDetectorType
from autoscript_tem_microscope_client.enumerations import CameraType, FixedReadoutArea, RegionCoordinateSystem, ExposureTimeType
from autoscript_tem_microscope_client.enumerations import RegionCoordinateSystem, ExposureTimeType
from autoscript_tem_microscope_client.structures import Region, Rectangle
from autoscript_tem_microscope_client.structures import StemAcquisitionSettings, EdsAcquisitionSettings, RunOptiStemSettings, CameraAcquisitionSettings, StemDataSettings
from autoscript_tem_microscope_client.structures import StemAcquisitionSettings, EdsAcquisitionSettings, RunOptiStemSettings, CameraAcquisitionSettings

_AUTOSCRIPT_AVAILABLE = True
except ImportError:
_AUTOSCRIPT_AVAILABLE = False

AUTOSCRIPT_STEM_OUTPUT_DIRECTORY = Path(r"Y:\AutoScript TEM")


class AutoScriptMicroscope(ElectronMicroscope):
"""
Expand Down Expand Up @@ -187,7 +190,7 @@ def _connect_detector_proxies(self) -> None:
continue
try:
proxy = tango.DeviceProxy(address)
proxy.set_timeout_millis(12_000)
proxy.set_timeout_millis(3_600_000 if name == "data" else 12_000)
self._detector_proxies[name] = proxy
self.info_stream(f"Connected to detector proxy: {name} @ {address}")
except tango.DevFailed as e:
Expand Down Expand Up @@ -276,36 +279,24 @@ def _acquire_camera_image(
detector: str,
readout_area: str,
frame_combining: int = 1,
electron_counting: bool = True,
output_format: str = ".h5",
) -> str:
"""
Call advanced AutoScript camera acquisition, save the adorned image,
and return its DATA/Tiled key.
"""
camera_detector = {
"Flucam": CameraType.FLUCAM,
"BM-Ceta": CameraType.BM_CETA,
"EF-Ceta": CameraType.EF_CETA,
"BM-Falcon": CameraType.BM_FALCON,
"EF-Falcon": CameraType.EF_FALCON,
"BM-Empad": CameraType.BM_EMPAD,
"SH-Empad": CameraType.SH_EMPAD,
"EF-CCD": CameraType.EF_CCD,
"EF-Empad": CameraType.EF_EMPAD,
}.get(detector, detector)
camera_detector = {"flucam": "Flucam", "bm-ceta": "BM-Ceta"}.get(detector.lower(), detector)
fixed_readout_area = {
"Full": FixedReadoutArea.FULL,
"Half": FixedReadoutArea.HALF,
"Quarter": FixedReadoutArea.QUARTER,
}.get(readout_area, readout_area)
"full": "Full",
"half": "Half",
"quarter": "Quarter",
}.get(readout_area.lower(), readout_area)
settings = CameraAcquisitionSettings(
camera_detector=camera_detector,
size=imsize,
exposure_time=exposure_time,
fixed_readout_area=fixed_readout_area,
frame_combining=frame_combining,
electron_counting=electron_counting,
)
adorned = self._microscope.acquisition.acquire_camera_image_advanced(settings)
data_server = self._detector_proxies.get("data")
Expand All @@ -318,21 +309,66 @@ def _acquire_camera_image(
output_format=output_format,
)


# the following file is COMPLETELY CORRECT, except the AutoScript acquire_stem_data_advanced is currently trash.
# it only records the origin corner of the camera, which is covered by the HAADF and ureachable at short camera lengths.
# waiting on an email back with a fix. -Austin Houston, Aug 11th 2026
'''
def _acquire_scanned_data_advanced(self, imsize: int, dwell_time: float, detector: str, scan_region: list[float]) -> str:
"""
Trigger AutoScript advanced scanned data acquisition with a camera detector.
"""Acquire AutoScript 4D-STEM data and return its registered HDF5 key."""
data_server = self._detector_proxies.get("data")
if not AUTOSCRIPT_STEM_OUTPUT_DIRECTORY.is_dir():
raise FileNotFoundError(f"AutoScript STEM output directory is not readable: {AUTOSCRIPT_STEM_OUTPUT_DIRECTORY}")

files_before = set(AUTOSCRIPT_STEM_OUTPUT_DIRECTORY.glob("*.mrc"))
region = Region(RegionCoordinateSystem.RELATIVE, Rectangle(*scan_region))
settings = StemDataSettings(dwell_time=dwell_time, detector_types=[CameraType.BM_CETA], size=imsize, region=region)
self._microscope.acquisition.acquire_stem_data_advanced(settings)
for _ in range(11):
new_files = set(AUTOSCRIPT_STEM_OUTPUT_DIRECTORY.glob("*.mrc")) - files_before
if new_files:
break
time.sleep(0.5)

if len(new_files) != 1:
raise RuntimeError(f"Expected one new AutoScript MRC file, found {len(new_files)}")

source_path = new_files.pop()
request = {
"source_path": str(source_path),
"detector": detector,
"scan_shape": [int(imsize), int(imsize)],
"dwell_time": float(dwell_time),
"scan_region": list(scan_region),
}
return data_server.copy_and_register_remote_file(json.dumps(request))
'''

AutoScript offloads the 4D scanned data storage for Ceta acquisitions, so
this command returns an acknowledgement and the settings used rather
than a local saved file path.
"""
camera_detector = CameraType.BM_CETA if detector == "BM-Ceta" else detector
settings = StemDataSettings(dwell_time=dwell_time, detector_types=[camera_detector], size=imsize, region=Region(RegionCoordinateSystem.RELATIVE, Rectangle(*scan_region)))
adorned = self._microscope.acquisition.acquire_stem_data_advanced(settings)
def _acquire_scanned_data_advanced(self, imsize: int, dwell_time: float, detector: str, scan_region: list[float]) -> str:
"""Acquire 4D-STEM data by placing the probe and recording one Ceta image per scan point."""
scan_size = int(imsize)
left, top, width, height = [float(value) for value in scan_region]
x_positions = left + (np.arange(scan_size) + 0.5) * width / scan_size
y_positions = top + (np.arange(scan_size) + 0.5) * height / scan_size
camera_detector = {"flucam": "Flucam", "bm-ceta": "BM-Ceta"}.get(detector.lower(), detector)
settings = CameraAcquisitionSettings(camera_detector=camera_detector, size=256, exposure_time=dwell_time, fixed_readout_area="Half")
starting_beam_position = self._microscope.optics.paused_scan_beam_position
frames = []
try:
for y_position in y_positions:
for x_position in x_positions:
self._microscope.optics.paused_scan_beam_position = [float(x_position), float(y_position)]
image = self._microscope.acquisition.acquire_camera_image_advanced(settings)
frames.append(np.array(image.data, copy=True))
finally:
self._microscope.optics.paused_scan_beam_position = starting_beam_position

stem_data = np.stack(frames).reshape(scan_size, scan_size, 256, 256)
attrs = {"scan_shape": [scan_size, scan_size], "diffraction_shape": [256, 256], "dwell_time": float(dwell_time), "scan_region": [left, top, width, height], "readout_area": "Half"}
data_server = self._detector_proxies.get("data")
return save_acquisition(self, data_server, "stem_data", str(detector), adorned, dataset_name="stem_data")
return save_acquisition(self, data_server, "stem_data", camera_detector, stem_data, dataset_name="stem_data", dataset_attrs=attrs)


# test: not sure this is how we want to save
def _acquire_spectrum(self, detector_name: str, exposure_time: float) -> str:
settings = EdsAcquisitionSettings()
settings.eds_detector = EdsDetectorType.SUPER_X
Expand Down
Loading
Loading