From 1d35ecf1e53e321fafc64d94bf965827e634c847 Mon Sep 17 00:00:00 2001 From: sligara7 Date: Wed, 21 Oct 2026 16:50:31 -0400 Subject: [PATCH 1/3] Tomo flyscan: correct time-based trigger spacing and centre averaged projections Time-based triggers were spaced by move_time / num_pulses, so 19 projections over 0-180 stepped 9.47 deg and ended at 170.5; they now use num_pulses - 1, as position-based already did. With images_to_average > 1 the triggers are spread over a slightly wider range so each group's mean lands on start + k*(stop - start)/(N - 1). The tomo exporter writes rotation_angle (and any other 2D PandA capture) as one mean per projection, keeping the raw capture as _raw. Assisted-by: copilot:claude-opus-5-5 --- src/hextools/exporters/tomography.py | 19 +++++++ src/hextools/exporters/utils.py | 20 ++++++++ src/hextools/flyers.py | 40 ++++++++++++--- src/hextools/tomography/flyscans.py | 6 ++- tests/test_export_averaging.py | 30 +++++++++++ tests/test_flyers.py | 76 ++++++++++++++++++++++++++++ 6 files changed, 182 insertions(+), 9 deletions(-) create mode 100644 tests/test_export_averaging.py create mode 100644 tests/test_flyers.py diff --git a/src/hextools/exporters/tomography.py b/src/hextools/exporters/tomography.py index 5495638..ded9da1 100644 --- a/src/hextools/exporters/tomography.py +++ b/src/hextools/exporters/tomography.py @@ -8,6 +8,7 @@ from bluesky_tiled_plugins.clients.bluesky_run import BlueskyRunV3 from hextools.exporters.utils import ( + average_per_projection, confirm_required_start_fields, create_baseline_dataset, find_hdf_datasets, @@ -76,6 +77,21 @@ def export_tomo_flyscan_to_h5( for name, (relpath, dataset, _) in primary.items(): links["data" if single_det and name in det_names else name] = (relpath, dataset) + # PandA captures with one row per projection (e.g. rotation_angle when + # averaging) are written as per-projection means; the raw link is kept. + images_to_average = run_start.get("images_to_average", 1) + if not isinstance(images_to_average, int): + raise ValueError(f"images_to_average is not an int: {images_to_average!r}") + averaged = {} + for name, (_, _, ndim) in primary.items(): + if name in det_names or ndim != 2: + continue + averaged[name] = average_per_projection( + run["primary"][name].read(), images_to_average + ) + links[f"{name}_raw"] = links.pop(name) + logger.info(f"Averaging {name} over {images_to_average} images per projection") + if dark_flat_run is not None: for stream_name in ("dark", "flat"): found = find_hdf_datasets(dark_flat_run, stream_name, scan_id_dir_path) @@ -99,6 +115,9 @@ def export_tomo_flyscan_to_h5( for link_name, (relpath, dataset) in links.items(): data_grp[link_name] = h5py.ExternalLink(relpath, dataset) logger.info(f"Linked /entry/data/{link_name} -> {relpath}:{dataset}") + for name, values in averaged.items(): + data_grp.create_dataset(name, data=values) + logger.info(f"Wrote /entry/data/{name} ({len(values)} projections)") logger.info("Writing baseline data") create_baseline_dataset(run, h5file) diff --git a/src/hextools/exporters/utils.py b/src/hextools/exporters/utils.py index 6bc32c3..66ead22 100644 --- a/src/hextools/exporters/utils.py +++ b/src/hextools/exporters/utils.py @@ -51,6 +51,26 @@ def confirm_required_start_fields(run_start: dict[str, Any]) -> tuple[str, str, return tuple(field_values) # type: ignore +def average_per_projection(values: np.ndarray, images_to_average: int) -> np.ndarray: + """Average a PandA capture to one value per projection. + + With ``images_to_average`` > 1 the PandA records every exposure, so Tiled + shapes the capture (projections, images_to_average). The averaged detector + frame belongs at the mean of its exposures' values. + """ + values = np.asarray(values) + if values.ndim == 1 and images_to_average == 1: + return values + if values.ndim == 2 and values.shape[1] == images_to_average: + return values.mean(axis=1) + if values.ndim == 2 and values.shape[0] == images_to_average: + return values.mean(axis=0) + raise ValueError( + f"Capture shape {values.shape} does not match " + f"images_to_average={images_to_average}" + ) + + def create_baseline_dataset(run: BlueskyRunV3, h5file: h5py.File): if "baseline" not in run: logger.warning("No baseline data found in the run.") diff --git a/src/hextools/flyers.py b/src/hextools/flyers.py index adf2772..b861e79 100644 --- a/src/hextools/flyers.py +++ b/src/hextools/flyers.py @@ -152,21 +152,46 @@ def construct_fly_info_models( time_based: bool = False, position_dataset_name: str = "rotation_angle", position_dataset_units: str = "deg", + pulses_per_projection: int = 1, ) -> tuple[SingleAxisFlyscanInfo, FlyMotorInfo]: """Construct the fly info models for a single axis flyscan. + ``start_position`` and ``stop_position`` are the angles of the first and last + projection. With ``pulses_per_projection`` > 1 (images averaged into one + projection), the triggers are spread evenly over a slightly wider range so + that each group of ``pulses_per_projection`` triggers is centred on its + projection angle. Recorded positions stay relative to ``start_position``. + Returns ------- tuple[SingleAxisFlyscanInfo, FlyMotorInfo] The fly info models for a single axis flyscan. """ - dist_to_start = (start_position - current_position) / encoder_resolution + if pulses_per_projection < 1 or num_pulses % pulses_per_projection: + raise ValueError( + f"num_pulses ({num_pulses}) must be a whole multiple of " + f"pulses_per_projection ({pulses_per_projection})." + ) + num_projections = num_pulses // pulses_per_projection + margin = 0.0 + if pulses_per_projection > 1 and num_projections > 1: + pulse_spacing = (stop_position - start_position) / ( + (num_projections - 1) * pulses_per_projection + ) + margin = (pulses_per_projection - 1) / 2 * pulse_spacing + trigger_start = start_position - margin + trigger_stop = stop_position + margin + + projection_start_in_counts = round( + current_enc_position + (start_position - current_position) / encoder_resolution + ) + dist_to_start = (trigger_start - current_position) / encoder_resolution start_in_counts = round(current_enc_position + dist_to_start) - signed_travel_counts = round((stop_position - start_position) / encoder_resolution) + signed_travel_counts = round((trigger_stop - trigger_start) / encoder_resolution) travel_counts = abs(signed_travel_counts) move_time = calculate_move_time_for_flyscan( - abs(stop_position - start_position), + abs(trigger_stop - trigger_start), max_motor_velocity, num_pulses, max_exposure_time, @@ -192,7 +217,8 @@ def construct_fly_info_models( pulse_step = travel_counts // (num_pulses - 1) else: pulse_width = max_exposure_time - pulse_step = move_time / num_pulses + # The first pulse fires at the start, so num_pulses - 1 steps reach the stop. + pulse_step = move_time / (num_pulses - 1) flyer_info = SingleAxisFlyscanInfo( start=start_in_counts, @@ -206,12 +232,12 @@ def construct_fly_info_models( position_dataset_name=position_dataset_name, position_dataset_units=position_dataset_units, position_scale=encoder_resolution, - position_offset=(-1 * start_in_counts * encoder_resolution), + position_offset=(-1 * projection_start_in_counts * encoder_resolution), ) motor_info = FlyMotorInfo( - start_position=start_position, - end_position=stop_position, + start_position=trigger_start, + end_position=trigger_stop, time_for_move=move_time, ) return flyer_info, motor_info diff --git a/src/hextools/tomography/flyscans.py b/src/hextools/tomography/flyscans.py index 35655d5..4179f92 100644 --- a/src/hextools/tomography/flyscans.py +++ b/src/hextools/tomography/flyscans.py @@ -236,9 +236,10 @@ def tomo_flyscan( images_to_average : int The number of images to average for each projection. start : float - The starting angle for the tomography fly scan. + The angle of the first projection. When averaging, the stage starts a + little earlier so the averaged images are centred on it. stop : float - The stopping angle for the tomography fly scan. + The angle of the last projection (same note as ``start``). use_shutter : bool Whether to use the shutter during the scan. sample_name : str | None @@ -334,6 +335,7 @@ def _body(): max_motor_velocity=max_velocity, acq_time_overhead=overhead, time_based=time_based, + pulses_per_projection=images_to_average, ) if use_shutter: diff --git a/tests/test_export_averaging.py b/tests/test_export_averaging.py new file mode 100644 index 0000000..def62ab --- /dev/null +++ b/tests/test_export_averaging.py @@ -0,0 +1,30 @@ +"""Averaging PandA captures down to one value per projection for the .nxs export.""" + +import numpy as np +import pytest + +from hextools.exporters.utils import average_per_projection + + +def test_averages_each_projections_captures(): + # 19 projections x 5 captures, as Tiled shapes a PandA dataset with + # collections_per_event=5. + centres = np.linspace(0, 180, 19) + captures = centres[:, None] + np.array([-4, -2, 0, 2, 4]) + np.testing.assert_allclose(average_per_projection(captures, 5), centres) + + +def test_accepts_captures_first(): + centres = np.linspace(0, 180, 19) + captures = (centres[:, None] + np.array([-4, -2, 0, 2, 4])).T + np.testing.assert_allclose(average_per_projection(captures, 5), centres) + + +def test_leaves_one_capture_per_projection_alone(): + angles = np.linspace(0, 180, 19) + np.testing.assert_array_equal(average_per_projection(angles, 1), angles) + + +def test_refuses_a_shape_that_does_not_match(): + with pytest.raises(ValueError, match="images_to_average"): + average_per_projection(np.zeros((19, 4)), 5) diff --git a/tests/test_flyers.py b/tests/test_flyers.py new file mode 100644 index 0000000..95eadca --- /dev/null +++ b/tests/test_flyers.py @@ -0,0 +1,76 @@ +"""Where a single-axis flyscan's triggers land, for both trigger modes.""" + +import numpy as np +import pytest +from ophyd_async.fastcs.panda import PandaPcompDirection + +from hextools.flyers import construct_fly_info_models + +RES = 0.001 # deg per encoder count + + +def _trigger_angles(flyer_info, motor_info, start): + """Angles the PandA records at each trigger, relative to ``start``.""" + k = np.arange(flyer_info.num_pulses) + if not flyer_info.time_based: + sign = 1 if flyer_info.direction == PandaPcompDirection.POSITIVE else -1 + counts = flyer_info.start + sign * k * flyer_info.pulse_step + return counts * flyer_info.position_scale + flyer_info.position_offset + # Constant velocity between start_position and end_position over time_for_move. + velocity = ( + motor_info.end_position - motor_info.start_position + ) / motor_info.time_for_move + positions = motor_info.start_position + velocity * k * flyer_info.pulse_step + return positions - start + + +def _models(num_projections, images_to_average, time_based, start=0.0, stop=180.0): + extra = ( + {"pulses_per_projection": images_to_average} if images_to_average > 1 else {} + ) + return construct_fly_info_models( + num_pulses=num_projections * images_to_average, + max_exposure_time=0.01, + start_position=start, + stop_position=stop, + encoder_resolution=RES, + max_motor_velocity=10.0, + current_position=0.0, + current_enc_position=0, + time_based=time_based, + **extra, + ) + + +@pytest.mark.parametrize("time_based", [False, True]) +@pytest.mark.parametrize(("start", "stop"), [(0.0, 180.0), (180.0, 0.0)]) +def test_triggers_span_start_to_stop(time_based, start, stop): + flyer_info, motor_info = _models(19, 1, time_based, start, stop) + angles = _trigger_angles(flyer_info, motor_info, start) + np.testing.assert_allclose(angles, np.linspace(0, stop - start, 19), atol=1e-9) + + +@pytest.mark.parametrize("time_based", [False, True]) +def test_averaged_projections_centre_on_requested_angles(time_based): + flyer_info, motor_info = _models(19, 5, time_based) + angles = _trigger_angles(flyer_info, motor_info, 0.0) + np.testing.assert_allclose( + angles.reshape(19, 5).mean(axis=1), np.linspace(0, 180, 19), atol=1e-9 + ) + # Every trigger is evenly spaced, groups included. + np.testing.assert_allclose(np.diff(angles), 2.0, atol=1e-9) + + +def test_pulses_must_split_into_projections(): + with pytest.raises(ValueError, match="pulses_per_projection"): + construct_fly_info_models( + num_pulses=96, + max_exposure_time=0.01, + start_position=0.0, + stop_position=180.0, + encoder_resolution=RES, + max_motor_velocity=10.0, + current_position=0.0, + current_enc_position=0, + pulses_per_projection=5, + ) From 88207764d60104ca0a4a6a173e1ce272b9a4c204 Mon Sep 17 00:00:00 2001 From: sligara7 Date: Wed, 21 Oct 2026 17:01:01 -0400 Subject: [PATCH 2/3] Address review: average captures over the images axis only; keep single time-based pulse average_per_projection now takes num_projections and accepts only (projections, images_to_average), the layout ophyd-async's datakey shape gives Tiled; a captures-first or mismatched array is refused instead of guessed, which a square array could get wrong. A time-based scan with one pulse uses move_time as its step again instead of dividing by zero. Tests for both. Assisted-by: copilot:claude-opus-5-5 --- src/hextools/exporters/tomography.py | 5 ++++- src/hextools/exporters/utils.py | 22 ++++++++++++---------- src/hextools/flyers.py | 2 +- tests/test_export_averaging.py | 26 +++++++++++++++++--------- tests/test_flyers.py | 6 ++++++ 5 files changed, 40 insertions(+), 21 deletions(-) diff --git a/src/hextools/exporters/tomography.py b/src/hextools/exporters/tomography.py index ded9da1..1cb55ef 100644 --- a/src/hextools/exporters/tomography.py +++ b/src/hextools/exporters/tomography.py @@ -80,14 +80,17 @@ def export_tomo_flyscan_to_h5( # PandA captures with one row per projection (e.g. rotation_angle when # averaging) are written as per-projection means; the raw link is kept. images_to_average = run_start.get("images_to_average", 1) + num_projections = run_start.get("num_points") if not isinstance(images_to_average, int): raise ValueError(f"images_to_average is not an int: {images_to_average!r}") averaged = {} for name, (_, _, ndim) in primary.items(): if name in det_names or ndim != 2: continue + if not isinstance(num_projections, int): + raise ValueError(f"num_points is not an int: {num_projections!r}") averaged[name] = average_per_projection( - run["primary"][name].read(), images_to_average + run["primary"][name].read(), num_projections, images_to_average ) links[f"{name}_raw"] = links.pop(name) logger.info(f"Averaging {name} over {images_to_average} images per projection") diff --git a/src/hextools/exporters/utils.py b/src/hextools/exporters/utils.py index 66ead22..ef36203 100644 --- a/src/hextools/exporters/utils.py +++ b/src/hextools/exporters/utils.py @@ -51,23 +51,25 @@ def confirm_required_start_fields(run_start: dict[str, Any]) -> tuple[str, str, return tuple(field_values) # type: ignore -def average_per_projection(values: np.ndarray, images_to_average: int) -> np.ndarray: +def average_per_projection( + values: np.ndarray, num_projections: int, images_to_average: int +) -> np.ndarray: """Average a PandA capture to one value per projection. - With ``images_to_average`` > 1 the PandA records every exposure, so Tiled - shapes the capture (projections, images_to_average). The averaged detector - frame belongs at the mean of its exposures' values. + With ``images_to_average`` > 1 the PandA records every exposure. ophyd-async + describes each event as ``images_to_average`` values, so Tiled shapes the + capture (projections, images_to_average); any other shape is refused rather + than guessed. The averaged detector frame belongs at the mean of its + exposures' values. """ values = np.asarray(values) - if values.ndim == 1 and images_to_average == 1: + if images_to_average == 1 and values.shape == (num_projections,): return values - if values.ndim == 2 and values.shape[1] == images_to_average: + if values.shape == (num_projections, images_to_average): return values.mean(axis=1) - if values.ndim == 2 and values.shape[0] == images_to_average: - return values.mean(axis=0) raise ValueError( - f"Capture shape {values.shape} does not match " - f"images_to_average={images_to_average}" + f"Capture shape {values.shape} is not (num_projections, images_to_average)" + f" = ({num_projections}, {images_to_average})" ) diff --git a/src/hextools/flyers.py b/src/hextools/flyers.py index b861e79..0d71ccd 100644 --- a/src/hextools/flyers.py +++ b/src/hextools/flyers.py @@ -218,7 +218,7 @@ def construct_fly_info_models( else: pulse_width = max_exposure_time # The first pulse fires at the start, so num_pulses - 1 steps reach the stop. - pulse_step = move_time / (num_pulses - 1) + pulse_step = move_time / (num_pulses - 1) if num_pulses > 1 else move_time flyer_info = SingleAxisFlyscanInfo( start=start_in_counts, diff --git a/tests/test_export_averaging.py b/tests/test_export_averaging.py index def62ab..b8b9fec 100644 --- a/tests/test_export_averaging.py +++ b/tests/test_export_averaging.py @@ -5,26 +5,34 @@ from hextools.exporters.utils import average_per_projection +OFFSETS = np.array([-4, -2, 0, 2, 4]) + def test_averages_each_projections_captures(): # 19 projections x 5 captures, as Tiled shapes a PandA dataset with - # collections_per_event=5. + # collections_per_event=5 (events first). centres = np.linspace(0, 180, 19) - captures = centres[:, None] + np.array([-4, -2, 0, 2, 4]) - np.testing.assert_allclose(average_per_projection(captures, 5), centres) + captures = centres[:, None] + OFFSETS + np.testing.assert_allclose(average_per_projection(captures, 19, 5), centres) -def test_accepts_captures_first(): - centres = np.linspace(0, 180, 19) - captures = (centres[:, None] + np.array([-4, -2, 0, 2, 4])).T - np.testing.assert_allclose(average_per_projection(captures, 5), centres) +def test_square_capture_averages_over_images(): + centres = np.linspace(0, 180, 5) + captures = centres[:, None] + OFFSETS + np.testing.assert_allclose(average_per_projection(captures, 5, 5), centres) + + +def test_refuses_captures_first(): + captures = (np.linspace(0, 180, 19)[:, None] + OFFSETS).T + with pytest.raises(ValueError, match="images_to_average"): + average_per_projection(captures, 19, 5) def test_leaves_one_capture_per_projection_alone(): angles = np.linspace(0, 180, 19) - np.testing.assert_array_equal(average_per_projection(angles, 1), angles) + np.testing.assert_array_equal(average_per_projection(angles, 19, 1), angles) def test_refuses_a_shape_that_does_not_match(): with pytest.raises(ValueError, match="images_to_average"): - average_per_projection(np.zeros((19, 4)), 5) + average_per_projection(np.zeros((19, 4)), 19, 5) diff --git a/tests/test_flyers.py b/tests/test_flyers.py index 95eadca..2ccdb34 100644 --- a/tests/test_flyers.py +++ b/tests/test_flyers.py @@ -74,3 +74,9 @@ def test_pulses_must_split_into_projections(): current_enc_position=0, pulses_per_projection=5, ) + + +def test_single_time_based_pulse_fires_at_start(): + flyer_info, motor_info = _models(1, 1, time_based=True) + assert flyer_info.num_pulses == 1 + np.testing.assert_allclose(_trigger_angles(flyer_info, motor_info, 0.0), [0.0]) From 6ec7d56f3bdf6f2ae2f88d6dd2ee34fbac71746f Mon Sep 17 00:00:00 2001 From: sligara7 Date: Thu, 22 Oct 2026 13:55:14 -0400 Subject: [PATCH 3/3] Drop the averaged-scan start/stop shift; record each projection at its images' mean angle Per Nghia: triggers run evenly from start to stop, and an averaged projection's angle is the mean of the PandA angles of its images. When averaging, every PandA capture goes through the shape check, so a flattened capture is refused. Assisted-by: copilot:claude-opus-5-5 --- src/hextools/exporters/tomography.py | 6 ++--- src/hextools/flyers.py | 37 +++++---------------------- src/hextools/tomography/flyscans.py | 6 ++--- tests/test_export_averaging.py | 5 ++++ tests/test_flyers.py | 38 ++++++++++------------------ 5 files changed, 30 insertions(+), 62 deletions(-) diff --git a/src/hextools/exporters/tomography.py b/src/hextools/exporters/tomography.py index 1cb55ef..3238adb 100644 --- a/src/hextools/exporters/tomography.py +++ b/src/hextools/exporters/tomography.py @@ -77,15 +77,15 @@ def export_tomo_flyscan_to_h5( for name, (relpath, dataset, _) in primary.items(): links["data" if single_det and name in det_names else name] = (relpath, dataset) - # PandA captures with one row per projection (e.g. rotation_angle when - # averaging) are written as per-projection means; the raw link is kept. + # When averaging, every PandA capture (e.g. rotation_angle) is written as the + # mean of each projection's images; the raw link is kept. images_to_average = run_start.get("images_to_average", 1) num_projections = run_start.get("num_points") if not isinstance(images_to_average, int): raise ValueError(f"images_to_average is not an int: {images_to_average!r}") averaged = {} for name, (_, _, ndim) in primary.items(): - if name in det_names or ndim != 2: + if name in det_names or (images_to_average == 1 and ndim == 1): continue if not isinstance(num_projections, int): raise ValueError(f"num_points is not an int: {num_projections!r}") diff --git a/src/hextools/flyers.py b/src/hextools/flyers.py index 0d71ccd..c8eac81 100644 --- a/src/hextools/flyers.py +++ b/src/hextools/flyers.py @@ -152,46 +152,21 @@ def construct_fly_info_models( time_based: bool = False, position_dataset_name: str = "rotation_angle", position_dataset_units: str = "deg", - pulses_per_projection: int = 1, ) -> tuple[SingleAxisFlyscanInfo, FlyMotorInfo]: """Construct the fly info models for a single axis flyscan. - ``start_position`` and ``stop_position`` are the angles of the first and last - projection. With ``pulses_per_projection`` > 1 (images averaged into one - projection), the triggers are spread evenly over a slightly wider range so - that each group of ``pulses_per_projection`` triggers is centred on its - projection angle. Recorded positions stay relative to ``start_position``. - Returns ------- tuple[SingleAxisFlyscanInfo, FlyMotorInfo] The fly info models for a single axis flyscan. """ - if pulses_per_projection < 1 or num_pulses % pulses_per_projection: - raise ValueError( - f"num_pulses ({num_pulses}) must be a whole multiple of " - f"pulses_per_projection ({pulses_per_projection})." - ) - num_projections = num_pulses // pulses_per_projection - margin = 0.0 - if pulses_per_projection > 1 and num_projections > 1: - pulse_spacing = (stop_position - start_position) / ( - (num_projections - 1) * pulses_per_projection - ) - margin = (pulses_per_projection - 1) / 2 * pulse_spacing - trigger_start = start_position - margin - trigger_stop = stop_position + margin - - projection_start_in_counts = round( - current_enc_position + (start_position - current_position) / encoder_resolution - ) - dist_to_start = (trigger_start - current_position) / encoder_resolution + dist_to_start = (start_position - current_position) / encoder_resolution start_in_counts = round(current_enc_position + dist_to_start) - signed_travel_counts = round((trigger_stop - trigger_start) / encoder_resolution) + signed_travel_counts = round((stop_position - start_position) / encoder_resolution) travel_counts = abs(signed_travel_counts) move_time = calculate_move_time_for_flyscan( - abs(trigger_stop - trigger_start), + abs(stop_position - start_position), max_motor_velocity, num_pulses, max_exposure_time, @@ -232,12 +207,12 @@ def construct_fly_info_models( position_dataset_name=position_dataset_name, position_dataset_units=position_dataset_units, position_scale=encoder_resolution, - position_offset=(-1 * projection_start_in_counts * encoder_resolution), + position_offset=(-1 * start_in_counts * encoder_resolution), ) motor_info = FlyMotorInfo( - start_position=trigger_start, - end_position=trigger_stop, + start_position=start_position, + end_position=stop_position, time_for_move=move_time, ) return flyer_info, motor_info diff --git a/src/hextools/tomography/flyscans.py b/src/hextools/tomography/flyscans.py index 4179f92..35655d5 100644 --- a/src/hextools/tomography/flyscans.py +++ b/src/hextools/tomography/flyscans.py @@ -236,10 +236,9 @@ def tomo_flyscan( images_to_average : int The number of images to average for each projection. start : float - The angle of the first projection. When averaging, the stage starts a - little earlier so the averaged images are centred on it. + The starting angle for the tomography fly scan. stop : float - The angle of the last projection (same note as ``start``). + The stopping angle for the tomography fly scan. use_shutter : bool Whether to use the shutter during the scan. sample_name : str | None @@ -335,7 +334,6 @@ def _body(): max_motor_velocity=max_velocity, acq_time_overhead=overhead, time_based=time_based, - pulses_per_projection=images_to_average, ) if use_shutter: diff --git a/tests/test_export_averaging.py b/tests/test_export_averaging.py index b8b9fec..4e6c843 100644 --- a/tests/test_export_averaging.py +++ b/tests/test_export_averaging.py @@ -36,3 +36,8 @@ def test_leaves_one_capture_per_projection_alone(): def test_refuses_a_shape_that_does_not_match(): with pytest.raises(ValueError, match="images_to_average"): average_per_projection(np.zeros((19, 4)), 19, 5) + + +def test_refuses_a_flattened_capture_when_averaging(): + with pytest.raises(ValueError, match="images_to_average"): + average_per_projection(np.zeros(95), 19, 5) diff --git a/tests/test_flyers.py b/tests/test_flyers.py index 2ccdb34..67e13c8 100644 --- a/tests/test_flyers.py +++ b/tests/test_flyers.py @@ -4,6 +4,7 @@ import pytest from ophyd_async.fastcs.panda import PandaPcompDirection +from hextools.exporters.utils import average_per_projection from hextools.flyers import construct_fly_info_models RES = 0.001 # deg per encoder count @@ -25,9 +26,6 @@ def _trigger_angles(flyer_info, motor_info, start): def _models(num_projections, images_to_average, time_based, start=0.0, stop=180.0): - extra = ( - {"pulses_per_projection": images_to_average} if images_to_average > 1 else {} - ) return construct_fly_info_models( num_pulses=num_projections * images_to_average, max_exposure_time=0.01, @@ -38,7 +36,6 @@ def _models(num_projections, images_to_average, time_based, start=0.0, stop=180. current_position=0.0, current_enc_position=0, time_based=time_based, - **extra, ) @@ -50,30 +47,23 @@ def test_triggers_span_start_to_stop(time_based, start, stop): np.testing.assert_allclose(angles, np.linspace(0, stop - start, 19), atol=1e-9) +# 19 x 5 triggers over 0-188 deg: 188000 counts split evenly into 94 steps. @pytest.mark.parametrize("time_based", [False, True]) -def test_averaged_projections_centre_on_requested_angles(time_based): - flyer_info, motor_info = _models(19, 5, time_based) +def test_averaged_triggers_span_start_to_stop(time_based): + flyer_info, motor_info = _models(19, 5, time_based, stop=188.0) angles = _trigger_angles(flyer_info, motor_info, 0.0) - np.testing.assert_allclose( - angles.reshape(19, 5).mean(axis=1), np.linspace(0, 180, 19), atol=1e-9 - ) - # Every trigger is evenly spaced, groups included. - np.testing.assert_allclose(np.diff(angles), 2.0, atol=1e-9) + np.testing.assert_allclose(angles, np.linspace(0, 188, 95), atol=1e-9) -def test_pulses_must_split_into_projections(): - with pytest.raises(ValueError, match="pulses_per_projection"): - construct_fly_info_models( - num_pulses=96, - max_exposure_time=0.01, - start_position=0.0, - stop_position=180.0, - encoder_resolution=RES, - max_motor_velocity=10.0, - current_position=0.0, - current_enc_position=0, - pulses_per_projection=5, - ) +def test_averaged_projection_angle_is_mean_of_its_images(): + flyer_info, motor_info = _models(19, 5, time_based=True) + angles = _trigger_angles(flyer_info, motor_info, 0.0) + groups = angles.reshape(19, 5) + np.testing.assert_allclose( + average_per_projection(groups, 19, 5), groups.sum(axis=1) / 5 + ) + assert angles[0] == pytest.approx(0.0) + assert angles[-1] == pytest.approx(180.0) def test_single_time_based_pulse_fires_at_start():