Merge branch 'visit-secondary-locations-first' into 'v3'

Visit secondary locations first

See merge request openflexure/openflexure-microscope-server!444
This commit is contained in:
Julian Stirling 2025-12-01 16:31:24 +00:00
commit d3bd921db2
5 changed files with 63 additions and 21 deletions

View file

@ -409,7 +409,9 @@ class SmartSpiral(ScanPlanner):
self._add_surrounding_positions(xy_pos)
else:
self._add_intermediate_positions(xy_pos)
self._re_sort_remaining_locations(xy_pos)
# Don't re-sort after imaging a secondary location or it can cause scan
# direction to reverse, breaking the spiral.
self._re_sort_remaining_locations(xy_pos)
def _add_surrounding_positions(self, xy_pos: XYPos) -> None:
"""Add the 4 surrounding positions to the list of remaining locations to visit.
@ -502,8 +504,9 @@ class SmartSpiral(ScanPlanner):
"""Sort the remaining positions based on the current location."""
# Defined rather than use a lambda for readability
def sort_key(pos: FutureScanLocation) -> tuple[float, float, float]:
def sort_key(pos: FutureScanLocation) -> tuple[bool, float, float, float]:
return (
self._is_primary_location(pos), # False sorts low
self.moves_between(current_pos, pos),
self.moves_between(self._initial_position, pos),
distance_between(current_pos, pos),

77
tests/utilities/scan_test_helpers.py Normal file → Executable file
View file

@ -1,9 +1,12 @@
#! /usr/bin/env python3
"""Utility functions for testing scan planners.
These including fake sample creation, scan path visualisation, and
persistent storage of expected scan paths for samples.
"""
import argparse
import os
import pickle
@ -18,6 +21,8 @@ from openflexure_microscope_server import scan_planners
THIS_DIR = os.path.dirname(os.path.realpath(__file__))
ALL_SAMPLE_NAMES = ("lobed", "regular", "core")
class FakeSample:
"""A fake sample to test scan algorithms.
@ -62,7 +67,10 @@ def visualise_scan(sample: FakeSample, planner: scan_planners.ScanPlanner) -> Fi
ax.add_artist(sample.patch)
xh, yh = zip(*planner.path_history, strict=True)
xi, yi, _zi = zip(*planner.imaged_locations, strict=True)
xs, ys, _zs = zip(*planner.secondary_locations, strict=True)
if planner.secondary_locations:
xs, ys, _zs = zip(*planner.secondary_locations, strict=True)
else:
xs, ys, _zs = [], [], []
# convert history to numpy array so can calculate quiver arrows
xh = np.array(xh)
@ -135,33 +143,33 @@ def example_smart_spiral(
return sample, planner
def profile_and_save_plot_for_example_smart_spiral():
"""Run the example scan and save a plot and the profile data.
Also print the cumulative stats.
This runs if you run this file directly.
"""
def profile_example_smart_spiral():
"""Profile running an example scan and print the cumulative profile stats."""
import pstats
import cProfile
profiler = cProfile.Profile()
sample, planner = profiler.runcall(example_smart_spiral)
profiler.runcall(example_smart_spiral)
stats_fname = os.path.join(THIS_DIR, "scan_example_stats.pstats")
profiler.dump_stats(stats_fname)
png_fname = os.path.join(THIS_DIR, "scan_example_plot.png")
fig = visualise_scan(sample, planner)
fig.savefig(png_fname, dpi=200)
run_stats = pstats.Stats(stats_fname)
run_stats.strip_dirs()
run_stats.sort_stats("cumulative")
run_stats.print_stats("scan_planners.py")
def update_example_smart_spiral_pickle(sample_name: str):
def plot_all_examples():
"""Plot all examples as png files."""
for sample_name in ALL_SAMPLE_NAMES:
sample, planner = example_smart_spiral(sample_name)
png_fname = os.path.join(THIS_DIR, f"scan_example_plot_{sample_name}.png")
fig = visualise_scan(sample, planner)
fig.savefig(png_fname, dpi=200)
def update_example_smart_spiral_pickles():
"""Pickle the ScanPlanner for the example_smart_spiral().
This is done so the history can be compared by testing to check
@ -173,10 +181,11 @@ def update_example_smart_spiral_pickle(sample_name: str):
Takes sample, the sample type we have generated, so we can make a
pickle for each sample type
"""
pkl_fname = os.path.join(THIS_DIR, f"example_smart_spiral_{sample_name}.pkl")
_, planner = example_smart_spiral(sample_name)
with open(pkl_fname, "wb") as pkl_file_obj:
pickle.dump(planner, pkl_file_obj, pickle.HIGHEST_PROTOCOL)
for sample_name in ALL_SAMPLE_NAMES:
pkl_fname = os.path.join(THIS_DIR, f"example_smart_spiral_{sample_name}.pkl")
_, planner = example_smart_spiral(sample_name)
with open(pkl_fname, "wb") as pkl_file_obj:
pickle.dump(planner, pkl_file_obj, pickle.HIGHEST_PROTOCOL)
def get_expected_result_for_example_smart_spiral(
@ -227,5 +236,35 @@ def load_sample_points(sample_name: str):
return sample_options[sample_name]
def main():
"""Run the profiler, the plotting, or update the pickles based on command line input.
This only runs if run as a command line script.
"""
parser = argparse.ArgumentParser(description="Simulated scan-planning utility")
subparsers = parser.add_subparsers(dest="command", required=True)
# profile
subparsers.add_parser("profile", help="Run simulation under cProfile")
# plot
subparsers.add_parser("plot", help="Run simulation and produce plots")
# update
subparsers.add_parser("update", help="Update stored reference pickles")
args = parser.parse_args()
if args.command == "profile":
profile_example_smart_spiral()
elif args.command == "plot":
plot_all_examples()
elif args.command == "update":
update_example_smart_spiral_pickles()
else:
parser.error(f"Unknown command: {args.command}")
if __name__ == "__main__":
profile_and_save_plot_for_example_smart_spiral()
main()