From cd53024c4f9cee684464d719147c81220907b5a3 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 28 Nov 2025 09:52:09 +0000 Subject: [PATCH 1/4] Update the scan test helpers so it is easier to save and plot --- tests/utilities/scan_test_helpers.py | 72 +++++++++++++++++++++------- 1 file changed, 54 insertions(+), 18 deletions(-) mode change 100644 => 100755 tests/utilities/scan_test_helpers.py diff --git a/tests/utilities/scan_test_helpers.py b/tests/utilities/scan_test_helpers.py old mode 100644 new mode 100755 index 4d32a2fb..aa45f442 --- a/tests/utilities/scan_test_helpers.py +++ b/tests/utilities/scan_test_helpers.py @@ -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. @@ -135,33 +140,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 +178,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 +233,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() From 90be1395756b90e8017e9ffe0b04793501ff4bc8 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 28 Nov 2025 09:53:24 +0000 Subject: [PATCH 2/4] Update scan planners so secondary locations are imaged first and don't affect later planning --- src/openflexure_microscope_server/scan_planners.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/openflexure_microscope_server/scan_planners.py b/src/openflexure_microscope_server/scan_planners.py index 2248a55c..11ea5abf 100644 --- a/src/openflexure_microscope_server/scan_planners.py +++ b/src/openflexure_microscope_server/scan_planners.py @@ -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. @@ -504,6 +506,7 @@ class SmartSpiral(ScanPlanner): # Defined rather than use a lambda for readability def sort_key(pos: FutureScanLocation) -> tuple[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), From dfe7fb6c42967716c0a76de00f3750302571f8d2 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 28 Nov 2025 10:13:19 +0000 Subject: [PATCH 3/4] Stop scan planner plots erroring if there are no secondary locations --- src/openflexure_microscope_server/scan_planners.py | 2 +- tests/utilities/scan_test_helpers.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/openflexure_microscope_server/scan_planners.py b/src/openflexure_microscope_server/scan_planners.py index 11ea5abf..3a215183 100644 --- a/src/openflexure_microscope_server/scan_planners.py +++ b/src/openflexure_microscope_server/scan_planners.py @@ -504,7 +504,7 @@ 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), diff --git a/tests/utilities/scan_test_helpers.py b/tests/utilities/scan_test_helpers.py index aa45f442..bf22876a 100755 --- a/tests/utilities/scan_test_helpers.py +++ b/tests/utilities/scan_test_helpers.py @@ -67,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) From a5d5ba4af80ea2298ef5e1ae6d4af6b39468d9e5 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 28 Nov 2025 10:14:05 +0000 Subject: [PATCH 4/4] Update scan planner test pickles --- tests/utilities/example_smart_spiral_core.pkl | Bin 10004 -> 10004 bytes .../utilities/example_smart_spiral_lobed.pkl | Bin 18596 -> 18596 bytes .../example_smart_spiral_regular.pkl | Bin 12935 -> 12935 bytes 3 files changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/utilities/example_smart_spiral_core.pkl b/tests/utilities/example_smart_spiral_core.pkl index b3b47db8adccf808706d79aa6eee7f0849f6b4a7..717d4eabababdaffc4fead93016b432d10fe20ee 100644 GIT binary patch delta 673 zcmbQ@H^py*ILl-{cF)O++0-UCu_;fEVN;uI#-=`5m_vCoADimr7p$t253p)Xj%Q|_ zJeO5vav`h6WKUMz$!e_XlUP+J-(pdlyoE)5a+SE^t4`(+SDpMoOk?s6G0n-wQp%J2Bm*XIU}2f;Am%gqjcCAR ze@52HlO>hG^hr^r$@fLPC+`wbovbOUKG}>zbMjku<;h3bH79>!Qkq=Ft~NQ6U1{=h zHjl}PoZgciI29*LajH)K#-TR(0*CtK)f{S*TRD^`$8e}kmf=>L{EbU(@&zun$*Z_D zC%1E{ObFw&>^5oB)N|RS|dQV=!>oK{8S8;L}uj*tYUbV?Qyy}ym^QcWe z$fGuSHjmn5Q-06MK0H2?czh;r;r5yA#9cHwK-6b)nxw{LJ4xNi(vs?vze=c1J};p* zd8LH<3+ zog68vJXuFpcQU7}`sBwls+0H1s7;A=Zh^7|$m$SY31FULBWOI~gA z6FIfX`{Yz7&yZ7{Tp*`0*+UKz#AjqfC+}7a0NatKs5?1?QF*eYqUPl93Tl&s#g!&6 zSJ0l^rl2-CK|ygcze3>TFcqK41}ch^?<=!T=2B6c{6tx8@;+s?$upEyCqI@^ob0Bo zGkKMg=HzIlfXSDYlqa((1x#MA#ya`8lK15GYOa$56u2j+s`*WpRr8sAQPp>Ho2t)b zD^;J#?^N8uW{N04%s!!_u=xqEALHga{sN}WCPG|{lk-G;HrojsFiw6aXg&FhngRd_ CIp8J$ delta 658 zcmbQ@H^py*ILl-ccHhZ|*?cCKvuRHbV^f`Mz@|ExgH2=d16Iw+J6V+{Cor>2p1`Uy z*`JYhawe=h2+(&Z9h8nMY~zO>XbW zReT*$vgr+n-41% zFiqa6Wi4OovH?mlWkRfCZ}6Rgfu7r7gU~nha+IJl4$7U|02qhFNx?*UN53P zxl=@Sah)FyX{sZCB1Q=M!lraD zj)(ZZ$!5|%lLe#|C-0YHo%~8lZSoN*waN3O)FxL*sZMt0P@JqLr8AjHN^|mUN#)6H zlA)6;WxXf=kqMoAQ$}NQpse!bBeDUL*U0Eh?v_!VoGPO>*;_Cr^?Ooa`;{Gx@)q&*aT=-jg%rye3P?IZpnfrT_rV-`DH_ diff --git a/tests/utilities/example_smart_spiral_lobed.pkl b/tests/utilities/example_smart_spiral_lobed.pkl index 21bcf3e1bbab6bcc69da41d7198655632dbeebb7..2ba515c9c67be8f84875242948d60cec6228689a 100644 GIT binary patch delta 431 zcmZ27k#WgH#tpKfljp0jPA=h7p8P?KW%EqYlT4E@s(Vger>-)&OI>Ahn!3tlCv}y{ za_Z`n|Ej4>zNw}%d8?YrpZrHvZSpl$waIp>YLh>yC{IpQ zRhulTsy4Y@)n~G>w$J3ZTAGtjX{k+KqNO^yK}&gZjF!q|3oX^jqFO4Gg}40$yS;wlf^VuCV$dUnJlH@F}Y1cd2*73^5jGfmC1kY*f;k`)UZsJ z6%Cl2&&Rr%SGJ33^JDo=#>w+l{3pxtu}v;ioIUwGFYD$+B@V{P)8&0OXDXX9PUcba z-yElH$hi5oPCDac3xh3_S7~@`eq(TobMk!`w#gyZ3X}g^ux@6zKFm1zzCG*YuQs`p z7qGET-fg8YIor;2@>+5B$v$@SlRt2wH4g5R&zrMue&?`|3B;J};bAj*fjjr+ me&YM7q-pGaQp%G* zN%>4RkxJ&vSCStjgqMS(VB5vMQ6MWmP7BmrPF|_+0pJj3}pUUJ9Vl10yioR#uyiWW--;h?DY^dTh`G>sEg4%stdqs%l_!7TV4M6wP8n?e2|0*;>T)WR8~9l!Uzhcn z>@BAO_Itam&t!Q%*2xue0h2|PyeFUMWu5#+Q5o!x!-~q2XDX^rE>={V?5n6g`2ai1 u}JWGhnl;8wACg* z&{CVcOG|a~6fM=s6O>seD{A>nmetajEYHU}`In~h3|ZL*$0>ExOE%99K9RVRDtL)@XRuRQq!2iqil z<;kn{0wyaNxlRsH=bn6?mv!=WL+{D{jI5K}4ZS9t8+uKCZQwI`zJc%L3T>awQqqr@ zHoM5>Gfpm4P}{s$VIt$?X-X3&>#MnMR#oO=p6sXOK3PS_Y L`^Gdm-ADld6FY0w