xptycho blueprints › Software Architecture
How the five kinds of quantities from the algorithm blueprint map onto objects. The layout is the mbirtorch pattern: one model holds the parameters and the forward model, and the unknowns pass through it as arguments and results. The unknowns are held in a Sample: an object and the probe it was seen with. A ground truth and a reconstruction are both a Sample; simulate takes one and recon returns one. Position refinement is a method the user calls (section 7).
ScanScan.load(path), by model.simulate, or by Scan(frames, probe_positions, …) after preprocessing raw frames.PtychoModelset_params can replace.configure_devices, and the batch size, set by name.Samplepixel_pitch.Sample, given to simulate. recon returns a Sample that also carries run, the record of how it was made. Drawn in section 3.The run state is the fourth kind and nobody holds it: it exists inside recon and is gone when recon returns.
from_scan. Fixed for the life of the model, except the scan positions, which set_params(probe_positions=...) can replace (section 7).set_params(...) and get_params(...), by name. The defaults are in the algorithm blueprint, section 7.object_data_fit \(\alpha_1\), probe_data_fit \(\alpha_2\)probe_weight_exponent \(\kappa\), relaxation \(\rho\)mode_schedule, mode_energy_fraction, orthogonalize_modesprobe_fresnel_radius_pixelsobject_shape, object_origin: the object grid, which must hold every patch; derived from the positions unless setconfigure_devices(...)set_params(batch_size=...)Derived, read-only: the object pixel pitch \(\lambda L / (N_p \Delta_{\mathrm{det}})\) (sample_pixel_pitch), the positions in pixels, the patch operators \(P_j\). The object grid shape and origin are derived from the positions unless set with object_shape and object_origin.
Written as a user calls them: PtychoModel(…) builds one, and model.name(…) is a method of the instance model. The one classmethod is marked.
PtychoModel(…) constructorBuild from the forward-model parameters.PtychoModel.from_scan(scan, num_probe_modes=1, **overrides) classmethodBuild from the facts a scan file recorded. Any parameter passed overrides the file's value.model.set_params(**p), model.get_params(names)Set and read the reconstruction parameters by name, batch_size among them, as in mbirtorch. set_params also takes probe_positions, to replace the positions (section 7). get_params also reads the forward-model parameters and sample_pixel_pitch.model.configure_devices(num_devices=1, devices=None)Choose the devices, as in mbirtorch: a count of GPUs, or an explicit list. Without a call the model chooses automatically. See Hardware Mapping, section 4.model.print_params()Print every parameter with its value, units, and origin (given, file, derived, default).model.forward(x, d)The forward model. For every scan position: cut the patch out of x, multiply by each probe mode, far-field transform, sum the modes in intensity. Returns the noiseless amplitudes \(y_j\), shape \((J, N_p, N_p)\). Stores nothing.model.simulate(sample, peak_photons=None, dark_rate=0, seed=0)Calls forward on the sample's object and probe, after checking that the sample's pixel_pitch equals the model's, scales the intensities so the brightest detector pixel is peak_photons, draws Poisson counts, and returns a Scan carrying this model's positions and instrument facts. Without peak_photons, the exact intensities.model.recon(scan, probe=None, init_probe=None, init=None, iterations=100, verbose=1)The reconstruction. With probe=, the probe is known and held fixed. Without it, the probe is estimated: from init_probe= when given, else from the data-driven initialization. A given probe= must have all \(K\) modes. An estimate may start with fewer: one mode from the initialization, or however many init_probe= has, and reaches \(K\) by adding a mode at each iteration of mode_schedule (algorithm blueprint, section 5). The schedule must have enough entries to reach \(K\), or recon refuses before it starts. init= warm-starts from a Sample, usually a previous result: its object, and its probe when the probe is estimated and init_probe= is not given. iterations is the one setting that changes on every call, so it is an argument here, as mbirtorch's max_iterations is. verbose=1 prints one line per iteration; 0 prints nothing. Returns a Sample with its run record. The run state lives only inside this call.model.data_error(scan, x, d)The normalized distance between forward(x, d) and the scan's amplitudes. The curve recon reports, available on its own.model.refine_probe_positions(scan, x, d, max_shift=1)One refinement pass: for each position, the integer shift within max_shift whose forward best matches the data, given x and d. Returns the refined positions and the per-position misfit. Changes nothing. Section 7.model.initial_probe(scan), model.initial_object(scan, d)The two initialization formulas of the algorithm blueprint, exposed so a user can look at the starting point. recon calls them when nothing is given.The forward model is written once, in forward. Everything else that needs it calls it.
Two more uses share the same forward: data_error, and refine_probe_positions, which compares forward at shifted positions against the data. A continued run is another recon call on the same model with init= the previous Sample. It rebuilds the per-position copies from the previous object and probe, so it is not identical to one uninterrupted run of the same length.
SampleA Sample holds the unknowns and what is needed to read them. One that recon returns also holds run, a record of the run; for a ground truth run is absent. The record is not a new kind of quantity; it is copies and derived values of what the model holds, frozen at the time of the run. Nothing of the run state and nothing of the measured data is in a Sample.
recon is passed back, so the sample is complete on its own.object: \(\hat x\), complex, on the object gridprobe: \(\hat d\), complex \((K, N_p, N_p)\)pixel_pitch: the spacing of the pixels, in metersorigin: the position of the center of the first pixel, in metersmode_energies: the share of the probe energy in each modename: a labelrun: the record of the runmodel.print_params() showed, plus what happened.run.parameters: every forward-model parameter, reconstruction parameter, and device setting, each with its value, units, and origin; also the iterations, whether the probe was given or estimated, and the run time in secondsrun.data_error: the data error at each iterationrun.iterations: the iterations runrun.probe_positions: the positions the run usedrun.coverage: the accumulated probe weight; zero where no probe reachedsample.summary()The sizes and the pixel pitch as text; for a reconstruction, also the parameter table, the iteration count, and the final data error.sample.save(path)Write the groups /sample and, for a reconstruction, /run of an HDF5 file (section 4).Sample.load(path) classmethodRead the sample, and the record of its run when there is one, from an HDF5 file.Mode addition is inside recon. The user sets the target and the timing: \(K\) on the model (num_probe_modes=2) is how many modes the run ends with; mode_schedule=[20] is the iterations at which a mode is added; mode_energy_fraction is the energy a new mode starts with. recon starts with one mode (or the modes of init_probe= or init=) and at each scheduled iteration runs the six steps of theory section 5. The user sees it in three places: the progress line printed at that iteration ("iteration 20: mode 2 added, 5% of the energy"); the convergence figure, which marks that iteration; and the result, recon.probe of shape \((K, N_p, N_p)\) with recon.mode_energies, the final share of each mode.
A scan and a sample are groups of an HDF5 file. One file may hold either or both. Each class reads and writes only its own groups and keeps the others, so a simulated dataset can be one file holding the scan and its truth. Lengths are in meters. Only functions named for it touch files: load, save, load_raw, download, and save_figures. Every other function takes and returns objects in memory, and the package holds no address, folder, or file name.
file.h5 attribute: format_version
/scan attributes: wavelength, det_distance, det_pixel_pitch, name
frames (J, Np, Np) intensities
probe_positions (J, 2), row and column of each probe center; attributes: units, order
/sample attributes: pixel_pitch, origin, name, units
object complex64 (rows, cols)
probe complex64 (K, Np, Np)
/run attribute: iterations. Present for a reconstruction.
parameters (N, 4) text: name, value, units, origin
data_error (iterations,)
probe_positions (J, 2), the positions the run used
coverage (rows, cols), the accumulated probe weight
| class | reads and writes |
|---|---|
Scan | /scan |
Sample | /sample, and /run when it has a record |
# 1. simulated scan, known probe (demo 1, TCI 2023) truth = xpt.Sample.load(xpt.download(TRUTH_URL, DATA_DIR)) # a Sample: truth.object, truth.probe, truth.pixel_pitch pixel_pitch = truth.pixel_pitch probe_positions = xpt.scan_positions((12, 12), 68 * pixel_pitch, 5 * pixel_pitch, seed=0) det_distance = pixel_pitch * 256 * 75e-6 / xpt.energy_to_wavelength(8.8) # object pixel = the truth's pixel pitch model = xpt.PtychoModel(energy=8.8, det_distance=det_distance, det_pixel_pitch=75e-6, frame_size=256, probe_positions=probe_positions) model.set_params(object_shape=truth.object.shape) # the object grid of the truth scan = model.simulate(truth, peak_photons=1e4, dark_rate=0.5, seed=0) model.set_params(object_data_fit=0.7, probe_weight_exponent=1.5, relaxation=0.5) model.print_params() recon = model.recon(scan, probe=truth.probe, iterations=100) # probe given, held fixed xpt.view_sample(recon, compare_to=truth) # the estimate beside the truth recon.save(OUTPUT_DIR + '/recon.h5') # 2. measured scan, blind, two modes (demo 3, TCI 2025); xpp is xptycho.preprocess raw = xpp.cxi.load_raw(xpt.download(RAW_URL, DATA_DIR)) frames = xpp.subtract_dark(raw['frames'], raw['dark_frames']) outlier = xpp.find_outlier_frames(frames, 2.0) frames, translations = frames[~outlier], raw['translations'][~outlier] frames = xpp.crop_frames(frames, xpp.diffraction_center(frames), 512) frames = frames * xpp.tukey_window(512, 0.5) ** 2 scan = xpt.Scan(frames, translations[:, [1, 0]], wavelength=raw['wavelength'], det_distance=raw['det_distance'], det_pixel_pitch=raw['det_pixel_pitch']) model = xpt.PtychoModel.from_scan(scan, num_probe_modes=2) # forward-model parameters from the scan model.set_params(object_data_fit=0.5, probe_data_fit=0.6, probe_weight_exponent=1.25, relaxation=0.5, mode_schedule=[10], mode_energy_fraction=0.05, orthogonalize_modes=True, probe_fresnel_radius_pixels=2.5) model.configure_devices(num_devices=4) # four GPUs; without this call, every GPU present model.set_params(batch_size=512) # optional; chosen from the free memory if not set recon = model.recon(scan, iterations=100) # probe not given, so estimated recon.probe # (2, 512, 512) recon = model.recon(scan, init=recon, iterations=100) # continue from the previous result recon.save(OUTPUT_DIR + '/recon.h5')
| kind | lives in |
|---|---|
| measured data | Scan |
| forward-model parameters | PtychoModel, fixed at construction except the positions, which set_params can replace; a copy of the recorded ones in Scan |
| unknowns | Sample: given to simulate, returned by recon |
| reconstruction parameters | PtychoModel, set_params |
| device settings | PtychoModel: the devices by configure_devices, the batch size by set_params |
| run state | inside recon |
Positions are a forward-model parameter, read from the scan file, and in most runs they are accurate enough to leave alone. Sometimes they are not, and the user usually knows which from the instrument. So refinement is a method the user calls, never something recon does on its own.
The method is Qiuchen's: for each position, try every integer shift within max_shift, run forward at each against the current object and probe, and keep the shift that best matches the measurement. It trusts the current image, so it is called after some iterations, and it costs 9 to 25 forward projections per position per pass.
recon = model.recon(scan, iterations=50) model.set_params(object_shape=recon.object.shape, object_origin=recon.origin) # keep the object grid fixed new_pos, misfit = model.refine_probe_positions(scan, recon.object, recon.probe, max_shift=1) model.set_params(probe_positions=new_pos) # accept the refined positions recon = model.recon(scan, init=recon, iterations=150) # continue with them
refine_probe_positions changes nothing. It returns the refined positions and, per position, how much better the best shift fits than the current one. A user who sees nothing to gain leaves the positions alone.set_params(probe_positions=...). The scan keeps the recorded positions, so the correction is always visible as the difference.recon then refuses init=recon because the previous object no longer has the grid's shape. refine_probe_positions tries only shifts that keep each patch inside the grid.recon calls, each a warm start from the previous Sample.