Skip to content

Commit 3febf81

Browse files
authored
Improve workflow (#125)
- migrate from gladier and fair-research-login to directly use globus-sdk - fix probe orthogonalization bug that was introduced in previous release - move data visualization methods to API - switch to colorcet for perceptually uniform color maps - improve forward model for synthetic diffraction pattern generation - improve API documentation & test coverage
1 parent 0ba6a16 commit 3febf81

79 files changed

Lines changed: 5757 additions & 1026 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/source/api.rst

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ Diffraction
2222
:undoc-members:
2323
:show-inheritance:
2424

25+
Diffraction Generators
26+
----------------------
27+
28+
.. automodule:: ptychodus.api.diffraction_gen
29+
:members:
30+
:undoc-members:
31+
:show-inheritance:
32+
2533
Fluorescence
2634
------------
2735

@@ -46,6 +54,14 @@ Input/Output (I/O)
4654
:undoc-members:
4755
:show-inheritance:
4856

57+
Interpolation
58+
-------------
59+
60+
.. automodule:: ptychodus.api.interpolate
61+
:members:
62+
:undoc-members:
63+
:show-inheritance:
64+
4965
Object
5066
------
5167

@@ -54,6 +70,14 @@ Object
5470
:undoc-members:
5571
:show-inheritance:
5672

73+
Object Generators
74+
-----------------
75+
76+
.. automodule:: ptychodus.api.object_gen
77+
:members:
78+
:undoc-members:
79+
:show-inheritance:
80+
5781
Observer
5882
--------
5983

@@ -86,6 +110,14 @@ Probe
86110
:undoc-members:
87111
:show-inheritance:
88112

113+
Probe Generators
114+
----------------
115+
116+
.. automodule:: ptychodus.api.probe_gen
117+
:members:
118+
:undoc-members:
119+
:show-inheritance:
120+
89121
Probe Positions
90122
---------------
91123

@@ -94,6 +126,14 @@ Probe Positions
94126
:undoc-members:
95127
:show-inheritance:
96128

129+
Probe Position Generators
130+
-------------------------
131+
132+
.. automodule:: ptychodus.api.probe_positions_gen
133+
:members:
134+
:undoc-members:
135+
:show-inheritance:
136+
97137
Product
98138
-------
99139

genesis.svg

Lines changed: 4 additions & 0 deletions
Loading

pyproject.toml

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[build-system]
2-
requires = ["setuptools>=80", "setuptools_scm[simple]>=8"]
2+
requires = ["setuptools>=80", "setuptools-scm[simple]>=9.2"]
33
build-backend = "setuptools.build_meta"
44

55
[project]
@@ -10,11 +10,13 @@ requires-python = ">=3.11"
1010
license = "BSD-3-Clause"
1111
license-files = ["LICENSE"]
1212
dependencies = [
13+
"colorcet",
1314
"h5py>=3",
1415
"hdf5plugin",
1516
"matplotlib",
1617
"numpy",
1718
"psutil",
19+
"pydantic",
1820
"pyyaml",
1921
"requests",
2022
"scikit-image",
@@ -31,10 +33,15 @@ ptychodus-bdp = "ptychodus.ptychodus_bdp:main"
3133
ptychodus-ptychopinn-tf-test = "ptychodus.scripts.ptychopinn_tf_test:main"
3234

3335
[project.optional-dependencies]
34-
globus = ["gladier", "gladier-tools>=0.5.4"]
36+
globus = ["globus-compute-sdk", "globus-sdk>=4.5.0"]
3537
gui = ["PyQt5"]
3638
#ptychonn = ["ptychonn==0.3.*,>=0.3.7"]
3739
ptychi = ["ptychi==1.*,>=1.2.0"]
40+
ptychopinn = ["ptychopinn[torch]"]
41+
42+
[tool.uv.sources]
43+
#ptychopinn = { path = "../PtychoPINN", editable = true }
44+
ptychopinn = { git = "https://github.com/hoidn/PtychoPINN", branch = "torchapi-devel" }
3845

3946
[tool.setuptools.package-data]
4047
"ptychodus" = ["py.typed"]
@@ -46,16 +53,14 @@ where = ["src"]
4653

4754
[[tool.mypy.overrides]]
4855
module = [
49-
"fair_research_login",
50-
"funcx_endpoint.*",
51-
"gladier.*",
56+
"colorcet",
5257
"globus_sdk.*",
5358
"h5py",
5459
"hdf5plugin",
5560
"lightning",
56-
"parsl.*",
5761
"ptychi.*",
5862
"ptycho.*",
63+
"ptycho_torch.*",
5964
"ptychonn.*",
6065
"pvaccess",
6166
"pvapy.*",

src/ptychodus/api/_phase_unwrapper.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
"""Phase unwrapping via gradient-domain integration for complex 2D wavefields."""
2+
13
from scipy import ndimage
24
from scipy.fft import fft, fft2, fftfreq, ifft, ifft2
35
from scipy.signal.windows import gaussian as gaussian_window
@@ -8,6 +10,8 @@
810

911

1012
class PhaseUnwrapper:
13+
"""Gradient-domain phase unwrapper for complex 2D images."""
14+
1115
def __init__(
1216
self,
1317
fourier_shift_step: float = 0.5,

src/ptychodus/api/common.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Common type aliases, physical constants, and utility functions used throughout the API."""
22

3+
from pathlib import Path
34
from typing import Any, Final, TypeAlias, overload
45

56
import numpy
@@ -24,6 +25,10 @@
2425
PLANCK_CONSTANT_J_PER_HZ: Final[float] = 6.62607015e-34
2526

2627

28+
def get_ptychodus_dir() -> Path:
29+
return Path.home() / '.ptychodus'
30+
31+
2732
@overload
2833
def lerp(lower: float, upper: float, frac: float) -> float: ...
2934
@overload
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Forward model for simulating diffraction patterns from a ptychography data product."""
2+
3+
import numpy
4+
5+
from scipy.fft import fft2, fftfreq, ifft2
6+
7+
from .diffraction import BadPixels, DiffractionIndexes, DiffractionPatterns
8+
from .geometry import PixelGeometry
9+
from .io import AssembledDiffractionData
10+
from .product import Product
11+
from .propagator import AngularSpectrumPropagator, FraunhoferPropagator, PropagatorParameters
12+
13+
14+
def generate_diffraction_data(
15+
product: Product, rng: numpy.random.Generator | None = None
16+
) -> AssembledDiffractionData:
17+
"""Simulate diffraction patterns for all scan positions in *product* using a multislice forward model.
18+
19+
If *rng* is provided, Poisson noise is added to the intensity patterns.
20+
"""
21+
object_ = product.object_
22+
probe_geometry = product.probes.get_geometry()
23+
24+
propagator_parameters = PropagatorParameters(
25+
wavelength_m=product.metadata.probe_wavelength_m,
26+
width_px=probe_geometry.width_px,
27+
height_px=probe_geometry.height_px,
28+
pixel_width_m=probe_geometry.pixel_width_m,
29+
pixel_height_m=probe_geometry.pixel_height_m,
30+
propagation_distance_m=product.metadata.detector_distance_m,
31+
)
32+
33+
# TODO also support near-field propagation
34+
propagator = FraunhoferPropagator(propagator_parameters)
35+
36+
# One angular-spectrum propagator per inter-layer gap
37+
interlayer_propagators = [
38+
AngularSpectrumPropagator(
39+
PropagatorParameters(
40+
wavelength_m=product.metadata.probe_wavelength_m,
41+
width_px=probe_geometry.width_px,
42+
height_px=probe_geometry.height_px,
43+
pixel_width_m=probe_geometry.pixel_width_m,
44+
pixel_height_m=probe_geometry.pixel_height_m,
45+
propagation_distance_m=spacing_m,
46+
)
47+
)
48+
for spacing_m in object_.layer_spacing_m
49+
]
50+
51+
num_positions = len(product.probe_positions)
52+
indexes: DiffractionIndexes = numpy.zeros(num_positions, dtype=int)
53+
patterns: DiffractionPatterns = numpy.zeros(
54+
(num_positions, probe_geometry.height_px, probe_geometry.width_px),
55+
dtype=float,
56+
)
57+
lambda_z_m2 = propagator_parameters.wavelength_m * propagator_parameters.propagation_distance_m
58+
pixel_geometry = PixelGeometry(
59+
width_m=lambda_z_m2 / probe_geometry.width_m,
60+
height_m=lambda_z_m2 / probe_geometry.height_m,
61+
)
62+
bad_pixels: BadPixels = numpy.full((probe_geometry.height_px, probe_geometry.width_px), False)
63+
64+
# Precompute frequency grids for Fourier shifting the probe
65+
freq_y = fftfreq(probe_geometry.height_px)
66+
freq_x = fftfreq(probe_geometry.width_px)
67+
fy, fx = numpy.meshgrid(freq_y, freq_x, indexing='ij')
68+
69+
object_geometry = object_.get_geometry()
70+
71+
for index, (probe_position, probe) in enumerate(zip(product.probe_positions, product.probes)):
72+
object_position = object_geometry.map_coordinates_probe_to_object(probe_position)
73+
74+
cx = object_position.coordinate_x_px
75+
cy = object_position.coordinate_y_px
76+
77+
x_lower = int(cx - probe_geometry.width_px / 2)
78+
y_lower = int(cy - probe_geometry.height_px / 2)
79+
80+
# Extract patches from all layers at the same integer position
81+
object_patches = [
82+
object_.get_layer(ilayer)[
83+
y_lower : y_lower + probe_geometry.height_px,
84+
x_lower : x_lower + probe_geometry.width_px,
85+
]
86+
for ilayer in range(object_.num_layers)
87+
]
88+
89+
# Subpixel offsets between the true position and the integer extraction position
90+
dx = cx - (x_lower + probe_geometry.width_px / 2)
91+
dy = cy - (y_lower + probe_geometry.height_px / 2)
92+
93+
# Shift all incoherent modes at once via a single batched FFT pair
94+
shift_phase = numpy.exp(-2j * numpy.pi * (fy * dy + fx * dx))
95+
shifted_modes = ifft2(fft2(probe.get_array()) * shift_phase)
96+
97+
for wavefield in shifted_modes:
98+
# Multislice: apply each layer then propagate to the next; last layer has no propagation
99+
for object_patch, interlayer_propagator in zip(object_patches, interlayer_propagators):
100+
wavefield = wavefield * object_patch
101+
wavefield = interlayer_propagator.propagate(wavefield)
102+
wavefield = wavefield * object_patches[-1]
103+
104+
wavefield = propagator.propagate(wavefield)
105+
patterns[index] += numpy.square(numpy.abs(wavefield))
106+
107+
indexes[index] = object_position.index
108+
109+
if rng is not None:
110+
# NOTE: object and probe scaling influence how much noise is added
111+
patterns = rng.poisson(patterns).astype(float)
112+
113+
return AssembledDiffractionData(indexes, patterns, pixel_geometry, bad_pixels)

src/ptychodus/api/geometry.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,8 @@ def __repr__(self) -> str:
178178

179179
@dataclass(frozen=True)
180180
class ZernikeMonomial:
181+
"""A single Zernike polynomial term with a complex coefficient, radial degree n, and angular frequency m."""
182+
181183
coefficient: complex
182184
radial_degree: int # n
183185
angular_frequency: int # m

src/ptychodus/model/analysis/interpolators.py renamed to src/ptychodus/api/interpolate.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
"""Sub-pixel array interpolation and stitching utilities."""
2+
13
from typing import Any, Generic, TypeVar
24
import logging
35

@@ -18,12 +20,15 @@
1820

1921

2022
def calculate_support_frac(x: float, n: int) -> tuple[slice, float]:
23+
"""Return the integer support slice and sub-pixel fractional offset for a width-n window centered at x."""
2124
lower = x - n / 2
2225
whole = int(lower)
2326
return slice(whole, whole + n + 1), lower - whole
2427

2528

2629
class NearestNeighborArrayInterpolator(Generic[InexactDType]):
30+
"""Extract patches from an array using integer (nearest-neighbor) pixel centering."""
31+
2732
def __init__(self, array: numpy.typing.NDArray[InexactDType]) -> None:
2833
super().__init__()
2934
self._array = array
@@ -43,6 +48,8 @@ def get_patch(
4348

4449

4550
class BarycentricArrayInterpolator(Generic[InexactDType]):
51+
"""Extract patches from an array using bilinear (barycentric) sub-pixel interpolation."""
52+
4653
def __init__(self, array: numpy.typing.NDArray[InexactDType]) -> None:
4754
super().__init__()
4855
self._array = array
@@ -72,6 +79,8 @@ def get_patch(
7279

7380

7481
class BarycentricArrayStitcher(Generic[InexactDType]):
82+
"""Accumulate weighted patches into a canvas using bilinear sub-pixel spreading, then normalize."""
83+
7584
def __init__(
7685
self, upper: numpy.typing.NDArray[InexactDType], lower: RealArrayType | None = None
7786
) -> None:
@@ -129,6 +138,7 @@ def add_patch(
129138
lsupport[..., 1:, 1:] += weight11 * weight
130139

131140
def stitch(self) -> numpy.typing.NDArray[InexactDType]:
141+
"""Return the accumulated canvas divided by the weight array; pixels with zero weight remain zero."""
132142
if self._lower is None:
133143
return self._upper
134144

src/ptychodus/api/io.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626

2727

2828
class StandardFileLayout(StrEnum):
29+
"""Conventional file names used in the ptychodus standard HDF5 workflow directory."""
30+
2931
DIFFRACTION = 'diffraction.h5'
3032
FLUORESCENCE_IN = 'fluorescence-in.h5'
3133
FLUORESCENCE_OUT = 'fluorescence-out.h5'
@@ -35,6 +37,8 @@ class StandardFileLayout(StrEnum):
3537

3638

3739
class DiffractionFileKeys(StrEnum):
40+
"""HDF5 dataset and attribute names for the assembled diffraction data file."""
41+
3842
PATTERNS = 'patterns'
3943
DETECTOR_PIXEL_HEIGHT = 'detector_pixel_height_m'
4044
DETECTOR_PIXEL_WIDTH = 'detector_pixel_width_m'
@@ -43,6 +47,7 @@ class DiffractionFileKeys(StrEnum):
4347

4448

4549
def load_diffraction_data(file: Path, *, mmap_file: Path | None = None) -> AssembledDiffractionData:
50+
"""Load assembled diffraction data from an HDF5 file written by :func:`save_diffraction_data`."""
4651
if mmap_file is not None:
4752
raise NotImplementedError('Load to memory map not implemented yet!')
4853

@@ -78,6 +83,7 @@ def load_diffraction_data(file: Path, *, mmap_file: Path | None = None) -> Assem
7883
def save_diffraction_data(
7984
file: Path, data: AssembledDiffractionData, *, compression: str = 'lzf'
8085
) -> None:
86+
"""Write assembled diffraction data to an HDF5 file."""
8187
with h5py.File(file, 'w') as h5_file:
8288
h5_file.create_dataset(
8389
DiffractionFileKeys.INDEXES, data=data._indexes, compression=compression
@@ -98,6 +104,8 @@ def save_diffraction_data(
98104

99105

100106
class ProductFileKeys(StrEnum):
107+
"""HDF5 dataset and attribute names for the product file."""
108+
101109
NAME = 'name'
102110
COMMENTS = 'comments'
103111
DETECTOR_OBJECT_DISTANCE = 'detector_object_distance_m'
@@ -124,6 +132,7 @@ class ProductFileKeys(StrEnum):
124132

125133

126134
def load_product(file: Path) -> Product:
135+
"""Load a data product from an HDF5 file written by :func:`save_product`."""
127136
point_list: list[ProbePosition] = []
128137

129138
with h5py.File(file, 'r') as h5_file:
@@ -257,6 +266,7 @@ def load_product(file: Path) -> Product:
257266

258267

259268
def save_product(file: Path, product: Product) -> None:
269+
"""Write a data product to an HDF5 file."""
260270
scan_indexes: list[int] = []
261271
scan_x_m: list[float] = []
262272
scan_y_m: list[float] = []

0 commit comments

Comments
 (0)