Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
4aabebe
Merge pull request #122 from pycroscopy/dev-ACH
AustinHouston Jun 18, 2026
30341cc
fix: updated dependencies because `pyqt5` doesn't have wheels for mac…
DomPTech Jun 29, 2026
fec06d8
fix: add missing parameter for file-based logging in `run_servers.py`
DomPTech Jun 29, 2026
0bac6db
feat(AI-use-CLaude-4.8: medium)(conscious-use: yes): jeol-> implement…
Jun 30, 2026
c0aaec3
fix(jeol): decode snapshot_rawdata bytes into a 2D image
Jun 30, 2026
0f4fc24
Merge pull request #123 from pycroscopy/dev-ACH
AustinHouston Jun 30, 2026
8066af5
Merge branch 'main' into main
AustinHouston Jun 30, 2026
58405af
Merge pull request #125 from DomPTech/main
AustinHouston Jun 30, 2026
eeb5b5c
Add PyJEM wheel and reorganize vendor stubs into versioned folders
Jun 30, 2026
d575899
add: _connect_hardware in jeol.py
Jun 30, 2026
b1a19de
refactor: align JEOL config + device properties to main's instrument …
Jun 30, 2026
93c89ef
Merge branch 'main' into jeol-cm
Jun 30, 2026
0a8302f
fix: hardcoded local path(eg: sqlite:///C:/Users/ahoust17/Desktop/) i…
Jun 30, 2026
853275e
feat: add AI agent proof-of-concept notebook with LangChain
DomPTech Jul 2, 2026
3ebf932
refactor and add: remove _persist and fake ReplicaAdornedImageJeol cl…
Jul 2, 2026
d3e48db
Merge pull request #126 from pycroscopy/jeol-cm
utkarshp1161 Jul 4, 2026
6896ec5
Fix: Clean up old github-pages artifacts before deployment
utkarshp1161 Jul 4, 2026
0a0b950
feat: update dependencies for agent and localagent in pyproject.toml,…
DomPTech Jul 6, 2026
7a51395
feat: tested AI agent notebook with local model, added cell for tool …
DomPTech Jul 6, 2026
5dd636e
Merge branch 'pycroscopy:main' into main
DomPTech Jul 6, 2026
48bfd75
chore: rename AI Agent demo notebook
DomPTech Jul 6, 2026
d1dd8e7
feat: clean up AI notebook code and add tool call previewing, as well…
DomPTech Jul 6, 2026
216e724
new functions and bug fixes
gduscher Jul 8, 2026
727d7ca
Merge pull request #127 from DomPTech/main
gduscher Jul 10, 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
6 changes: 5 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,15 @@ jobs:
cd docs
myst build --html
touch _build/html/.nojekyll
- name: Delete old GitHub Pages artifact
uses: geekyeggo/delete-artifact@v2
with:
name: github-pages
failOnError: false
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: 'docs/_build/html'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

13 changes: 5 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,18 +50,15 @@ See - docs/dev_guide.md

### Core installation (simulation mode)

```bash
pip install --find-links ./stubs -e .
```

or with `uv`:

```bash
uv sync
```

This installs `asyncroscopy` and all core dependencies. AutoScript is not required—the
framework will fall back to simulated acquisition automatically.
This installs `asyncroscopy` and all core dependencies. The vendor wheels are
local, version-pinned, and resolved via `[tool.uv.sources]` in `pyproject.toml`
(AutoScript under `stubs/AutoScript_v_1.17/`, PyJEM under
`stubs/PyJEM_v_1.3.0.3564/`). AutoScript/PyJEM hardware is not required—the
framework falls back to simulated acquisition automatically.

### Hardware installation (Thermo Fisher AutoScript)

Expand Down
41 changes: 40 additions & 1 deletion asyncroscopy/data/data_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import h5py
import numpy as np
import tifffile

DEFAULT_ACQUISITION_DIR = "outputs/tiled_acquisitions"

Expand All @@ -35,6 +36,21 @@ def acquisition_filename(
return directory / f"{acquisition_type}_{detector}_{stamp}.{extension.lower().lstrip('.')}"


def _write_tiff(source, path: Path, attrs: dict | None = None) -> None:
"""Write one image to ``path`` as TIFF.

AutoScript adorned images save themselves natively (embedding their own
metadata); anything else is a raw array (JEOL/twin) written via tifffile,
with ``attrs`` (e.g. JEOL's get_detectorsetting() dict) json-encoded into the
TIFF ImageDescription tag so the provenance survives the format.
"""
if hasattr(source, "save"): # adorned images from AutoScript have a save method
source.save(str(path))
else:
array = source.data if hasattr(source, "data") and not isinstance(source, np.ndarray) else source
tifffile.imwrite(str(path), np.asarray(array), description=json.dumps(attrs) if attrs else None)


def save_acquisition(
device,
data_server,
Expand All @@ -44,11 +60,31 @@ def save_acquisition(
dataset_name: str = "image",
dataset_attrs: dict | list[dict] | None = None,
file_attrs: dict | None = None,
output_format: str = ".h5",
) -> str:
"""Save one acquisition to one HDF5 file and return its DATA/Tiled key."""
"""Save one acquisition and return its DATA/Tiled key.

``.h5`` writes all detectors into one stacked file; ``.tiff`` writes one
file per detector sharing a timestamp (TIFF cannot stack), registering each.
"""
if output_format not in (".h5", ".tiff"):
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]
attrs_list = dataset_attrs if isinstance(dataset_attrs, list) else [dataset_attrs] * len(data_list)

if output_format == ".tiff":
save_dir = data_server.save_path if data_server is not None else DEFAULT_ACQUISITION_DIR
directory = Path(save_dir).expanduser()
directory.mkdir(parents=True, exist_ok=True)
stem = f"{acquisition_type}_{datetime.now().strftime('%Y%m%dT%H%M%S%f')}"
for index, (source, detector) in enumerate(zip(data_list, detector_list)):
path = directory / f"{stem}_{detector}.tiff"
_write_tiff(source, path, attrs_list[index])
if data_server is not None:
data_server.register_path(str(path))
return stem

has_labeled_datasets = len(detector_list) > 1 or len(data_list) > 1
detector_label = "_".join([str(detector) for detector in detector_list])
path = acquisition_filename(device, acquisition_type, detector_label, data_server)
Expand Down Expand Up @@ -98,3 +134,6 @@ def save_acquisition_hdf5(path: str | Path, datasets: list[dict], file_attrs: di
if key in dset.attrs:
key = f"{key}_{len(dset.attrs)}"
dset.attrs[key] = elem.text.strip()
elif isinstance(metadata, dict):
for key, value in metadata.items():
dset.attrs[key] = value if isinstance(value, (str, int, float, bool, np.number)) else json.dumps(value)
131 changes: 89 additions & 42 deletions asyncroscopy/instruments/electron_microscope/auto_script.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@

import math
import time
from datetime import datetime
from pathlib import Path
import json

import numpy as np
import tango
Expand Down Expand Up @@ -250,63 +249,35 @@ def register_stage(self):
# ------------------------------------------------------------------
# Internal acquisition helpers
# ------------------------------------------------------------------
def _persist(self, adorned, acquisition_type, detector, data_server, dataset_name="image"):
"""Save acquired images in the format requested by the SCAN device.
"""
scan = self._detector_proxies.get("scan")
fmt = scan.output_format if scan is not None else ".h5" # ".h5" default
if fmt == ".h5":
return save_acquisition(self, data_server, acquisition_type, detector, adorned, dataset_name=dataset_name)
if fmt != ".tiff":
raise ValueError(f"Unsupported output_format {fmt!r}; expected '.h5' or '.tiff'")

# .tiff → AutoScript native save, one file per detector sharing one stamp
images = list(adorned) if isinstance(adorned, (list, tuple)) else [adorned]
detectors = list(detector) if isinstance(detector, (list, tuple)) else [detector]
if len(images) != len(detectors):
raise ValueError(f"Got {len(images)} images for {len(detectors)} detector(s) {detectors}")

save_dir = data_server.save_path if data_server is not None else DEFAULT_ACQUISITION_DIR
directory = Path(save_dir).expanduser()
directory.mkdir(parents=True, exist_ok=True)
stamp = datetime.now().strftime("%Y%m%dT%H%M%S%f")
stem = f"{acquisition_type}_{stamp}"
# AutoScript returns images in the requested detector order (assumed; verify on hardware)
for img, det in zip(images, detectors):
path = directory / f"{stem}_{det}.tiff"
img.save(str(path))
if data_server is not None:
data_server.register_path(str(path))
return stem

def _acquire_scanned_image(
self,
imsize: int,
dwell_time: float,
detector_list: list[str] = ["haadf"],
scan_region: list[float] = [0.0, 0.0, 1.0, 1.0],
output_format: str = ".h5",
) -> str:
"""
Call AutoScript scanned image acquisition, save one HDF5 file, and return its DATA/Tiled key.
Call AutoScript scanned image acquisition, save it, and return its DATA/Tiled key.
"""
detector_list = [d.upper() for d in detector_list]
settings = StemAcquisitionSettings(dwell_time=dwell_time, detector_types=detector_list, size=imsize, region=Region(RegionCoordinateSystem.RELATIVE, Rectangle(*scan_region)))
adorned = self._microscope.acquisition.acquire_stem_images_advanced(settings)
if not isinstance(adorned, list):
adorned = [adorned]
data_server = self._detector_proxies.get("data")
return self._persist(adorned, "stem_image", detector_list, data_server)
return save_acquisition(self, data_server, "stem_image", detector_list, adorned, output_format=output_format)


def _acquire_camera_image(self, imsize: int, exposure_time: float, detector: str, readout_area: str) -> str:
"""
Call AutoScript acquisition, save the adorned image, and return its path.
Call AutoScript acquisition, save the adorned image, and return its DATA/Tiled key.
this is the advanced version
"""
settings = CameraAcquisitionSettings(camera_detector=detector, size=imsize, exposure_time=exposure_time, fixed_readout_area=readout_area, frame_combining=1)
adorned = self._microscope.acquisition.acquire_camera_image_advanced(settings)
data_server = self._detector_proxies.get("data")
return self._persist(adorned, "camera_image", str(detector), data_server)
return save_acquisition(self, data_server, "camera_image", str(detector), adorned)

def _acquire_scanned_data_advanced(self, imsize: int, dwell_time: float, detector: str, scan_region: list[float]) -> str:
"""
Expand Down Expand Up @@ -379,9 +350,11 @@ def _set_defocus(self, defocus) -> None:

def _get_defocus(self) -> float:
"""Get defocus in meters."""
return self._microscope.optics.defocus
return float(self._microscope.optics.defocus)


def _caibrate_screen_current(self) -> None:
def _calibrate_screen_current(self) -> None:
""" calibrate screen current with monchromator focus"""
original_gun_lens = self._microscope.optics.monochromator.focus
gun_lens_series = np.linspace(10, 150, 15)

Expand Down Expand Up @@ -411,7 +384,7 @@ def _set_screen_current(self, current) -> None:
self._microscope.optics.monochromator.focus = float(x_real)
else:
self.warn_stream("Screen current calibration not available. running calibration (should take 15 seconds).")
self._caibrate_screen_current()
self._calibrate_screen_current()

poly_func = self.screen_current_calibration
adjusted_poly = poly_func - current
Expand Down Expand Up @@ -443,6 +416,40 @@ def _get_stage(self):
return position
else:
return position[:4]


def _get_status(self):
status = {'system': self._microscope.service.system.name,
'vacuum': self._microscope.vacuum.state,
'column_valves': self._microscope.vacuum.column_valves.state,
'is_accelerator_on': self._microscope.optics.is_accelerator_on,
'acceleration_voltage': self._microscope.optics.acceleration_voltage.value,
'optical_mode': self._microscope.optics.optical_mode,
'illumination_mode': self._microscope.optics.illumination_mode,
'objective_lens_mode': self._microscope.optics.objective_lens_mode,
'projector_mode': self._microscope.optics.projector_mode,
#'convergence_angle': self._microscope.optics.convergence_angle,
'spot_size': self._microscope.optics.spot_size_index,
'beam_stopper': self._microscope.optics.beam_stopper.insertion_state,
'beam_blanker': self._microscope.optics.blanker.is_beam_blanked,
'is_eftem_on': self._microscope.optics.is_eftem_on,
}
if self._microscope.optics.optical_mode == 'Stem':
status['scan_rotation'] = self._microscope.optics.scan_rotation
status['scan_field_of_view'] = self._microscope.optics.scan_field_of_view
for mechanism_type in self._microscope.optics.aperture_mechanisms.get_available():
mechanism = self._microscope.optics.aperture_mechanisms.get_mechanism(mechanism_type)
if not mechanism.is_enabled:
status[mechanism_type] = 'Disabled'
elif mechanism.insertion_state == 'Retracted':
status[mechanism_type] = 'Retracted'
else:
status[mechanism_type] = mechanism.aperture.name
for deflector in self._microscope.optics.deflectors.get_available_deflectors():
defl = self._microscope.optics.deflectors.get_deflector_value(deflector)
status[deflector] = [defl.x, defl.y]

return json.dumps(status)

def _move_stage(self, position) -> None:
"""Move stage to specified position [x, y, z, alpha, beta]."""
Expand All @@ -459,7 +466,7 @@ def _move_stage(self, position) -> None:
beta = None

self._microscope.specimen.stage.absolute_move((x, y, z, alpha, beta))
self._get_stage() # link the proxy with real state
# self._get_stage() # link the proxy with real state

def _auto_focus(self):
"""Perform autofocus routine C1A1"""
Expand All @@ -471,11 +478,51 @@ def _set_image_shift(self, shift):
x_shift = float(shift[0])
y_shift = float(shift[1])
try:
self._microscope.optics.deflectors.beam_shift = (x_shift, y_shift)
if self._microscope.optics.optical_mode == 'Stem':
self._microscope.optics.deflectors.beam_shift = (x_shift, y_shift)
else:
self._microscope.optics.deflectors.image_shift = (x_shift, y_shift)
except Exception as e:
self.error_stream(f"Failed to set beam shift: {e}")

self.error_stream(f"Failed to set image shift: {e}")

def _set_diffraction_shift(self, shift):
"""Apply image shift in meters."""
x_shift = float(shift[0])
y_shift = float(shift[1])
try:
if self._microscope.optics.projector_mode == 'Diffraction':
self._microscope.optics.deflectors.image_shift = (x_shift, y_shift)
except Exception as e:
self.error_stream(f"Failed to set diffraction shift: {e}")

def _get_diffraction_shift(self):
if self._microscope.optics.optical_mode == 'Diffraction':
position = self._microscope.optics.deflectors.image_shift
return np.array([position.x, position.y])
else:
return np.array([0, 0])


def _get_image_shift(self):
if self._microscope.optics.optical_mode == 'Stem':
position = self._microscope.optics.deflectors.beam_shift
else:
position = self._microscope.optics.deflectors.image_shift
return np.array([position.x, position.y])

def _get_beam_tilt(self):
tilt = self._microscope.optics.deflectors.beam_tilt
return np.array([tilt .x, tilt.y])

def _set_beam_tilt(self, tilt):
"""Apply beam tilt in radians."""
x_tilt = float(tilt[0])
y_tilt = float(tilt[1])
try:
self._microscope.optics.deflectors.beam_tilt = (x_tilt, y_tilt)
except Exception as e:
self.error_stream(f"Failed to set beam tilt: {e}")

# ----------------------------------------------------------------------
# Server entry point
# ----------------------------------------------------------------------
Expand Down
5 changes: 3 additions & 2 deletions asyncroscopy/instruments/electron_microscope/digital_twin.py
Original file line number Diff line number Diff line change
Expand Up @@ -518,15 +518,16 @@ def _acquire_scanned_image(
dwell_time: float,
detector_list: list[str] = ["haadf"],
scan_region: list[float] = [0.0, 0.0, 1.0, 1.0],
output_format: str = ".h5",
) -> str:
"""Simulate STEM acquisition, save HDF5 data with metadata, and return its DATA/Tiled key."""
"""Simulate STEM acquisition, save the data with metadata, and return its DATA/Tiled key."""
detector_list = [detector.upper() for detector in detector_list]
data_server = self._detector_proxies.get("data")
images = []
for detector in detector_list:
image = self._render_stem_image(int(imsize), float(dwell_time), [detector])
images.append(image)
return save_acquisition(self, data_server, "stem_image", detector_list, images)
return save_acquisition(self, data_server, "stem_image", detector_list, images, output_format=output_format)

def _simulate_spectrum(self, detector_name: str, exposure_time: float) -> dict[str, float]:
"""Simulate EDS spectrum acquisition at the current beam position weighted by surrounding particles."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,7 @@ def _acquire_scanned_image(
dwell_time: float,
detector_list: list[str] = ['haadf'],
scan_region: list[float] = [0.0, 0.0, 1.0, 1.0],
output_format: str = '.h5',
) -> str:
image_size = int(self.overview_image_size)
detector_list = [detector.upper() for detector in detector_list]
Expand All @@ -369,7 +370,7 @@ def _acquire_scanned_image(
'sample_pixel_size_nm': float(FOV_NM / int(self.map_size)),
}
images = [self._render_stem_image(image_size, float(dwell_time), [detector]) for detector in detector_list]
return save_acquisition(self, data_server, 'stem_image', detector_list, images, dataset_attrs=attrs)
return save_acquisition(self, data_server, 'stem_image', detector_list, images, dataset_attrs=attrs, output_format=output_format)

def _set_fov(self, fov) -> None:
self.warn_stream('DigitalTwinDiffraction uses a fixed 500 nm field of view.')
Expand Down
Loading
Loading