Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 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
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
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
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
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
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
14 changes: 0 additions & 14 deletions asyncroscopy/instruments/electron_microscope/detectors/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,6 @@ class CAMERA(Device):
doc="Number of sub-frames combined by the camera (Ceta-specific).",
)

electron_counting = attribute(
label="Electron Counting",
dtype=bool,
access=AttrWriteType.READ_WRITE,
doc="Produce an electron-counted image on supported counting detectors.",
)

output_format = attribute(
label="Output Format",
dtype=str,
Expand All @@ -98,7 +91,6 @@ def init_device(self) -> None:
self._readout_area: str = "Full"
self._camera_detector: str = "BM-Ceta"
self._frame_combining: int = 1
self._electron_counting: bool = True
self._output_format: str = ".h5"

self.info_stream("CAMERA device initialised")
Expand Down Expand Up @@ -149,12 +141,6 @@ def write_frame_combining(self, value: int) -> None:
raise ValueError("frame_combining must be at least 1")
self._frame_combining = value

def read_electron_counting(self) -> bool:
return self._electron_counting

def write_electron_counting(self, value: bool) -> None:
self._electron_counting = value

def read_output_format(self) -> str:
return self._output_format

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,6 @@ def _acquire_camera_image(
detector: str,
readout_area: str,
frame_combining: int = 1,
electron_counting: bool = True,
output_format: str = '.h5',
) -> str:
particle, rattle_value = self._beam_particle()
Expand All @@ -346,7 +345,6 @@ def _acquire_camera_image(
'exposure_time': float(exposure_time),
'readout_area': str(readout_area),
'frame_combining': int(frame_combining),
'electron_counting': bool(electron_counting),
}
if particle is not None:
attrs.update(
Expand Down
Loading
Loading