From ea74909ee0f85f7b5dac11ad17cfc38468bc722b Mon Sep 17 00:00:00 2001 From: x12sa Date: Wed, 21 Oct 2026 16:47:27 +0200 Subject: [PATCH] feat(lamni): block tomo parameters when a stitch tile has too few points With a circular FOV and stitching, corner tiles can hold far fewer Fermat points than the center tile. A tile below the scan minimum aborts the projection, and the retry then stops the tomogram. The point-count check now uses the smallest tile (new lamni_smallest_tile_position_count()): the GUI refuses submit / add-to-queue, and tomo_parameters on the command line skips the confirm question and repeats the scan-geometry prompts until the count is sufficient. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017NrH3poJsYBCTVq2Y9fuHZ --- .../bec_ipython_client/plugins/LamNI/lamni.py | 153 +++++++++--------- .../plugins/OMNY_shared/omny_general_tools.py | 43 +++++ .../widgets/tomo_params/tomo_params.py | 58 +++---- docs/user/ptychography/lamni.md | 2 +- .../test_lamni_get_val_range.py | 20 +++ .../test_lamni_tomo_params_widget_math.py | 28 +++- .../test_stitch_tile_offsets.py | 18 +++ 7 files changed, 210 insertions(+), 112 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py index dcc50085..babd886e 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py @@ -22,6 +22,7 @@ from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import OMNYTools, PtychoReconstructor, TomoIDManager, + lamni_smallest_tile_position_count, stitch_tile_offsets, ) from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.angle_file import ( @@ -2563,49 +2564,32 @@ class LamNI( # ------------------------------------------------------------------ def _expected_fermat_position_count(self) -> int: - """Predict the number of Fermat-spiral scan positions the current - settings would produce per projection tile, using the exact same - algorithm LamniFermatScan runs at scan time - (LamniFermatScan.get_lamni_fermat_spiral_pos()) -- so a - too-few-points configuration (which the scan server would only - catch by aborting with ScanAbortion once the scan actually starts) - can be caught here instead, while just looking at tomo_parameters(). + """Predict the number of Fermat-spiral scan positions of the stitch + tile with the fewest points, using the exact same algorithm + LamniFermatScan runs at scan time (see + lamni_smallest_tile_position_count()) -- so a too-few-points + configuration (which the scan server would only catch by aborting + with ScanAbortion once the scan actually starts) can be caught here + instead. With a circular FOV the corner tiles can hold far fewer + points than the center tile; any tile below the minimum aborts the + whole projection. - center_x/center_y/shift_x/shift_y are deliberately left at their - defaults (0): they only shift the final absolute position, applied - after the FOV/circular-FOV keep-or-discard check, so they can never - affect the resulting count. Computed for the center stitch tile - (stitch_x=stitch_y=0) at angle=0, as a representative estimate -- - the real per-projection count can vary a bit with angle/stitch - tile, since the circular FOV crop (tomo_circfov) is checked against - the rotated stage position. - - The real scan also draws a fresh random sub-step offset on every - call (see get_lamni_fermat_spiral_pos()'s `seed` argument), adding a - further small amount of per-projection count variation on top of - the angle/stitch dependence above. A fixed seed is used here only so - that this estimate itself is reproducible from one call to the next. + center_x/center_y/shift_x/shift_y don't enter: they only shift the + final absolute position, applied after the FOV/circular-FOV + keep-or-discard check. The real scan also draws a fresh random + sub-step offset on every call, so the actual count can differ by a + few points per angle. """ - from csaxs_bec.scans.lamni_fermat_scan import LamniFermatScan - - positions = LamniFermatScan.get_lamni_fermat_spiral_pos( - -abs(self.lamni_piezo_range_x / 2), - abs(self.lamni_piezo_range_x / 2), - -abs(self.lamni_piezo_range_y / 2), - abs(self.lamni_piezo_range_y / 2), - step=self.tomo_shellstep, - spiral_type=0, - center=False, - angle=0.0, - stitch_x=0, - stitch_y=0, - stitch_overlap=self.tomo_stitch_overlap, - fovx=self.lamni_piezo_range_x, - fovy=self.lamni_piezo_range_y, - fov_circular=self.tomo_circfov, - seed=0, + count, _tile = lamni_smallest_tile_position_count( + self.lamni_piezo_range_x, + self.lamni_piezo_range_y, + self.tomo_shellstep, + self.lamni_stitch_x, + self.lamni_stitch_y, + self.tomo_stitch_overlap, + self.tomo_circfov, ) - return len(positions) + return count @staticmethod def _fermat_min_positions() -> int: @@ -2615,6 +2599,50 @@ class LamNI( return LamniFermatScan.MIN_POSITIONS + def _print_fermat_position_estimate(self) -> bool: + """Print the estimated Fermat point count (smallest stitch tile); + True if it is below the scan's minimum, i.e. the scan would abort.""" + expected_positions = self._expected_fermat_position_count() + min_positions = self._fermat_min_positions() + label = "Estimated Fermat scan points" + if self.lamni_stitch_x or self.lamni_stitch_y: + label += " (smallest stitch tile)" + if expected_positions < min_positions: + print( + f"{label}: {expected_positions} (WARNING: below the minimum of" + f" {min_positions} -- the scan will abort when run)" + ) + return True + print(f"{label}: {expected_positions}") + return False + + def _prompt_scan_geometry(self): + """tomo_parameters_cli() prompts that determine the Fermat point count.""" + self.tomo_shellstep = self._get_val( + " um", self.tomo_shellstep, float, min_=0.025, max_=20.0 + ) + self.lamni_piezo_range_x = self._get_val( + " um", self.lamni_piezo_range_x, float, min_=0.1, max_=79.9 + ) + self.lamni_piezo_range_y = self._get_val( + " um", self.lamni_piezo_range_y, float, min_=0.1, max_=79.9 + ) + # min 0: a negative stitch makes stitch_tile_offsets() empty, so a + # projection would silently run zero scans. stitch n gives n+1 tiles. + # max 50 = the GUI's range. + self.lamni_stitch_x = self._get_val( + " (n+1 tiles)", self.lamni_stitch_x, int, min_=0, max_=50 + ) + self.lamni_stitch_y = self._get_val( + " (n+1 tiles)", self.lamni_stitch_y, int, min_=0, max_=50 + ) + self.tomo_stitch_overlap = self._get_val( + " um", self.tomo_stitch_overlap, float, min_=0.0, max_=10.0 + ) + self.tomo_circfov = self._get_val( + " um", self.tomo_circfov, float, min_=0.0, max_=500.0 + ) + def tomo_parameters_cli(self): """Print and interactively update the tomo parameters on the command line. tomo_parameters() opens the GUI version.""" @@ -2631,15 +2659,7 @@ class LamNI( ) print(f"Stitching overlap = {self.tomo_stitch_overlap}") print(f"Circular FOV diam = {self.tomo_circfov}") - expected_positions = self._expected_fermat_position_count() - min_positions = self._fermat_min_positions() - if expected_positions < min_positions: - print( - f"Estimated Fermat scan points per projection: {expected_positions} " - f"(WARNING: below the minimum of {min_positions} -- the scan will abort when run)" - ) - else: - print(f"Estimated Fermat scan points per projection: {expected_positions}") + too_few_positions = self._print_fermat_position_estimate() print(f"Reconstruction queue name = {self.ptycho_reconstruct_foldername}") print(f"Frames per trigger (burst) = {self.frames_per_trigger}") print("FOV offset rotates to find the ROI; initial values determined in Xrayeye alignment.") @@ -2688,36 +2708,23 @@ class LamNI( self._print_angle_file_summary() print(f"\nSample name: {self.sample_name}\n") - if self.OMNYTools.yesno("Are these parameters correctly set for your scan?", "y"): + if too_few_positions: + print("Too few Fermat scan points -- the scan parameters have to be adjusted.") + elif self.OMNYTools.yesno("Are these parameters correctly set for your scan?", "y"): print("OK. continue.") return self.tomo_countingtime = self._get_val( " s", self.tomo_countingtime, float, min_=0.001, max_=100.0 ) - self.tomo_shellstep = self._get_val( - " um", self.tomo_shellstep, float, min_=0.025, max_=20.0 - ) - self.lamni_piezo_range_x = self._get_val( - " um", self.lamni_piezo_range_x, float, min_=0.1, max_=79.9 - ) - self.lamni_piezo_range_y = self._get_val( - " um", self.lamni_piezo_range_y, float, min_=0.1, max_=79.9 - ) - # min 0: a negative stitch makes stitch_tile_offsets() empty, so a - # projection would silently run zero scans. stitch n gives n+1 tiles. max 50 = the GUI's range. - self.lamni_stitch_x = self._get_val( - " (n+1 tiles)", self.lamni_stitch_x, int, min_=0, max_=50 - ) - self.lamni_stitch_y = self._get_val( - " (n+1 tiles)", self.lamni_stitch_y, int, min_=0, max_=50 - ) - self.tomo_stitch_overlap = self._get_val( - " um", self.tomo_stitch_overlap, float, min_=0.0, max_=10.0 - ) - self.tomo_circfov = self._get_val( - " um", self.tomo_circfov, float, min_=0.0, max_=500.0 - ) + while True: + self._prompt_scan_geometry() + if not self._print_fermat_position_estimate(): + break + print( + "Too few Fermat scan points: reduce the step size or the piezo range," + " or adjust stitching/circular FOV." + ) self.ptycho_reconstruct_foldername = self._get_val( "Reconstruction queue", self.ptycho_reconstruct_foldername, str ) diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/omny_general_tools.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/omny_general_tools.py index f5366dc4..14dfedc8 100644 --- a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/omny_general_tools.py +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/omny_general_tools.py @@ -42,6 +42,49 @@ def stitch_tile_offsets(n: int) -> list[float]: return [i - n / 2 for i in range(n + 1)] +def lamni_smallest_tile_position_count( + piezo_range_x: float, + piezo_range_y: float, + step: float, + stitch_x: int, + stitch_y: int, + stitch_overlap: float, + fov_circular: float, +) -> tuple[int, tuple[float, float]]: + """Fermat point count of the stitch tile with the fewest points, and that + tile's offsets. With a circular FOV the corner tiles can hold far fewer + points than the center one, and any tile below the scan's minimum aborts + the whole projection. Evaluated at angle 0: the per-tile count only + varies by a few points with angle (random sub-step offset), and seed=0 + keeps the estimate reproducible.""" + from csaxs_bec.scans.lamni_fermat_scan import LamniFermatScan + + counts = {} + for tile_x in stitch_tile_offsets(stitch_x): + for tile_y in stitch_tile_offsets(stitch_y): + counts[(tile_x, tile_y)] = len( + LamniFermatScan.get_lamni_fermat_spiral_pos( + -abs(piezo_range_x / 2), + abs(piezo_range_x / 2), + -abs(piezo_range_y / 2), + abs(piezo_range_y / 2), + step=step, + spiral_type=0, + center=False, + angle=0.0, + stitch_x=tile_x, + stitch_y=tile_y, + stitch_overlap=stitch_overlap, + fovx=piezo_range_x, + fovy=piezo_range_y, + fov_circular=fov_circular, + seed=0, + ) + ) + tile = min(counts, key=counts.get) + return counts[tile], tile + + class OMNYTools: HEADER = "\033[95m" diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py index 92942239..703c5913 100644 --- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -590,7 +590,9 @@ class OMNY_TomoParams(BECWidget, QWidget): # fov/step/stitch/piezo-range fields above change, flagged orange if # below the scan server's own minimum (see _update_fermat_position_preview()). self._lbl_fermat_positions = QLabel("---") - common_form.addRow("Estimated Fermat scan points:", self._lbl_fermat_positions) + common_form.addRow( + "Estimated Fermat scan points (smallest tile):", self._lbl_fermat_positions + ) for key in self._profile["fermat_position_fields"]: widget = self._pw.get(key) if widget is not None: @@ -1126,7 +1128,8 @@ class OMNY_TomoParams(BECWidget, QWidget): count, min_positions = self._profile["compute_fermat_positions"](params) if count < min_positions: return ( - f"Estimated Fermat scan points ({count}) is below the minimum of " + f"Estimated Fermat scan points ({count}, smallest stitch tile) is below the " + f"minimum of " f"{min_positions} required for the scan to run -- increase FOV, " "reduce step size, or adjust stitch/piezo range before submitting." ) @@ -2718,48 +2721,35 @@ def _compute_fermat_positions_flomni(params: dict[str, Any]) -> tuple[int, int]: def _compute_fermat_positions_lamni(params: dict[str, Any]) -> tuple[int, int]: - """Lamni sibling of _compute_fermat_positions_flomni(): calls - LamniFermatScan.get_lamni_fermat_spiral_pos() directly -- including its - rotated-stage and circular-FOV (tomo_circfov) cropping, which can matter - a lot and would be easy to get subtly wrong in a reimplementation. - Evaluated at the outermost tile of the currently edited stitch and angle=0 (lamni has - no angle field in this widget -- the real per-projection count can vary - a bit with angle, since the circular crop is angle-dependent). - - The real scan also draws a fresh random sub-step offset on every call - (see get_lamni_fermat_spiral_pos()'s `seed` argument), adding a further - small amount of per-projection count variation on top of the - angle/stitch dependence above. A fixed seed is used here only so this - estimate is itself reproducible. + """Lamni sibling of _compute_fermat_positions_flomni(), via + lamni_smallest_tile_position_count(): the real scan algorithm + (LamniFermatScan.get_lamni_fermat_spiral_pos(), including its + rotated-stage and circular-FOV cropping) run for every stitch tile, and + the smallest count returned. With a circular FOV the corner tiles can + fall below the minimum while the center tile is fine, and any such tile + aborts the projection, so submit is blocked on the smallest one. Returns: (estimated_count, minimum_required) """ + from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import ( + lamni_smallest_tile_position_count, + ) from csaxs_bec.scans.lamni_fermat_scan import LamniFermatScan - piezo_x = params.get("lamni_piezo_range_x", 0.0) - piezo_y = params.get("lamni_piezo_range_y", 0.0) step = params.get("tomo_shellstep", 0.0) if step <= 0: return 0, LamniFermatScan.MIN_POSITIONS - positions = LamniFermatScan.get_lamni_fermat_spiral_pos( - -abs(piezo_x / 2), - abs(piezo_x / 2), - -abs(piezo_y / 2), - abs(piezo_y / 2), - step=step, - spiral_type=0, - angle=0.0, - # outermost tile: stitch n gives n+1 tiles at offsets -n/2 .. +n/2 - stitch_x=params.get("lamni_stitch_x", 0) / 2, - stitch_y=params.get("lamni_stitch_y", 0) / 2, - stitch_overlap=params.get("tomo_stitch_overlap", 1.0), - fovx=piezo_x, - fovy=piezo_y, - fov_circular=params.get("tomo_circfov", 0.0), - seed=0, + count, _tile = lamni_smallest_tile_position_count( + params.get("lamni_piezo_range_x", 0.0), + params.get("lamni_piezo_range_y", 0.0), + step, + params.get("lamni_stitch_x", 0), + params.get("lamni_stitch_y", 0), + params.get("tomo_stitch_overlap", 1.0), + params.get("tomo_circfov", 0.0), ) - return len(positions), LamniFermatScan.MIN_POSITIONS + return count, LamniFermatScan.MIN_POSITIONS def _lamni_get_tomo_fov_offset(client, axis: str) -> float: diff --git a/docs/user/ptychography/lamni.md b/docs/user/ptychography/lamni.md index 67dc912d..a481ce5e 100644 --- a/docs/user/ptychography/lamni.md +++ b/docs/user/ptychography/lamni.md @@ -208,7 +208,7 @@ which reads the last recorded progress and resumes automatically at the exact po In case of eight equally spaced sub-tomograms, an individual sub-tomogram can be scanned by `lamni.sub_tomo_scan(subtomo_number, start_angle)`. If the start angle is not specified, it is computed depending on `subtomo_number`, ranging from 1 to 8. -`lamni.tomo_parameters()` also offers `lamni_stitch_x`/`lamni_stitch_y` (integers, default 0, up to 50 each) for a field of view larger than a single scan can cover: when nonzero, every projection angle is acquired as a `(lamni_stitch_x+1) × (lamni_stitch_y+1)` grid of separate Fermat scans instead of a single one, placed symmetrically around the normal alignment-corrected center and spaced by `lamni_piezo_range_x`/`lamni_piezo_range_y` minus `tomo_stitch_overlap` (µm, default 0.2) per step in x/y — stitching together an effective field of view of roughly `(lamni_stitch_x+1) × lamni_piezo_range_x` by `(lamni_stitch_y+1) × lamni_piezo_range_y`, with `tomo_stitch_overlap` controlling the deliberate overlap between adjacent tiles used for aligning them together during reconstruction. For example, a value of 1 gives two tiles, each shifted by half a step from the center; 2 gives three tiles (one centered, one on each side). Same mechanism as flomni's `stitch_x`/`stitch_y`, just under lamni's own parameter names (see `lamni.tomo_scan_projection()`, which passes `stitch_x`/`stitch_y`/`stitch_overlap` straight through to `scans.lamni_fermat_scan()`). +`lamni.tomo_parameters()` also offers `lamni_stitch_x`/`lamni_stitch_y` (integers, default 0, up to 50 each) for a field of view larger than a single scan can cover: when nonzero, every projection angle is acquired as a `(lamni_stitch_x+1) × (lamni_stitch_y+1)` grid of separate Fermat scans instead of a single one, placed symmetrically around the normal alignment-corrected center and spaced by `lamni_piezo_range_x`/`lamni_piezo_range_y` minus `tomo_stitch_overlap` (µm, default 0.2) per step in x/y — stitching together an effective field of view of roughly `(lamni_stitch_x+1) × lamni_piezo_range_x` by `(lamni_stitch_y+1) × lamni_piezo_range_y`, with `tomo_stitch_overlap` controlling the deliberate overlap between adjacent tiles used for aligning them together during reconstruction. For example, a value of 1 gives two tiles, each shifted by half a step from the center; 2 gives three tiles (one centered, one on each side). With a circular FOV (`tomo_circfov`) the corner tiles can contain far fewer scan points than the center one; the estimated point count shown by `tomo_parameters()` is that of the smallest tile, and the parameters cannot be submitted while it is below the scan minimum (20). Reduce the piezo range or the step size, or adjust the stitching or circular FOV. Same mechanism as flomni's `stitch_x`/`stitch_y`, just under lamni's own parameter names (see `lamni.tomo_scan_projection()`, which passes `stitch_x`/`stitch_y`/`stitch_overlap` straight through to `scans.lamni_fermat_scan()`). #### Angles from a file (tomo_type 4) diff --git a/tests/tests_bec_ipython_client/test_lamni_get_val_range.py b/tests/tests_bec_ipython_client/test_lamni_get_val_range.py index fddc72de..c24d8ae3 100644 --- a/tests/tests_bec_ipython_client/test_lamni_get_val_range.py +++ b/tests/tests_bec_ipython_client/test_lamni_get_val_range.py @@ -7,6 +7,7 @@ range, same mechanism as Flomni's. """ import builtins +import types from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI @@ -166,3 +167,22 @@ def test_cli_type3_rejects_zero_projections_per_subtomogram(monkeypatch): lamni.tomo_parameters_cli() assert lamni.tomo_angle_stepsize == 10.0 # 360 / 36 + + +def test_cli_too_few_points_skips_confirm_and_reprompts_geometry(monkeypatch, capsys): + """Too few Fermat points (e.g. a starved corner stitch tile): the + parameters cannot be confirmed as they are, and the scan-geometry prompts + repeat until the estimate reaches the minimum.""" + lamni = _make_cli_lamni(monkeypatch, {"20 um -> too few points + lamni._expected_fermat_position_count = lambda: 5 if lamni.lamni_piezo_range_x > 20 else 50 + lamni.lamni_piezo_range_x = 25.0 + + lamni.tomo_parameters_cli() + + assert lamni.lamni_piezo_range_x == 10.0 + assert not any("correctly set" in str(a) for a in asked) + out = capsys.readouterr().out + assert "Too few Fermat scan points" in out diff --git a/tests/tests_bec_ipython_client/test_lamni_tomo_params_widget_math.py b/tests/tests_bec_ipython_client/test_lamni_tomo_params_widget_math.py index 4b46baca..5a0c7fff 100644 --- a/tests/tests_bec_ipython_client/test_lamni_tomo_params_widget_math.py +++ b/tests/tests_bec_ipython_client/test_lamni_tomo_params_widget_math.py @@ -219,10 +219,8 @@ def test_compute_fermat_positions_lamni_matches_scan_class_above_threshold(): def test_compute_fermat_positions_lamni_uses_live_stitch_tile(): - """Unlike the CLI's tomo_parameters() (which always estimates the - center tile as a quick summary), the GUI has the actual stitch_x/y - fields right there, so it should reflect whichever tile is currently - being edited, not always stitch=0.""" + """The estimate is the smallest stitch tile's count, so it depends on + the stitch_x/y being edited, not always the center tile.""" base_params = { "lamni_piezo_range_x": 20.0, "lamni_piezo_range_y": 20.0, @@ -332,3 +330,25 @@ def test_flomni_manual_shift_fields_are_in_um(): assert "µm" in label assert by_key["manual_shift_x"] == (-10.0, 10.0) assert by_key["manual_shift_y"] == (-50.0, 50.0) + + +def test_compute_fermat_positions_lamni_blocks_on_starved_corner_tile(): + """Center tile fine, corner tiles below the minimum (3x3 tiles, 36 um + circular FOV): the estimate must be the corner count, so submit is + blocked as in the non-stitched case.""" + params = { + "lamni_piezo_range_x": 20.0, + "lamni_piezo_range_y": 20.0, + "tomo_shellstep": 1.0, + "tomo_stitch_overlap": 0.2, + "tomo_circfov": 36.0, + "lamni_stitch_x": 2, + "lamni_stitch_y": 2, + } + count, min_positions = _compute_fermat_positions_lamni(params) + assert count < min_positions + + count, min_positions = _compute_fermat_positions_lamni( + dict(params, lamni_piezo_range_x=10.0, lamni_piezo_range_y=10.0) + ) + assert count >= min_positions diff --git a/tests/tests_bec_ipython_client/test_stitch_tile_offsets.py b/tests/tests_bec_ipython_client/test_stitch_tile_offsets.py index bfed0c14..c71b79c3 100644 --- a/tests/tests_bec_ipython_client/test_stitch_tile_offsets.py +++ b/tests/tests_bec_ipython_client/test_stitch_tile_offsets.py @@ -23,3 +23,21 @@ def test_stitch_tile_offsets(n, expected): def test_stitch_tile_offsets_negative_is_empty(): assert stitch_tile_offsets(-1) == [] + + +def test_smallest_tile_count_finds_starved_corner_tile(): + """3x3 tiles, 20 um piezo range, 36 um circular FOV: the center tile is + full, but a corner tile holds only a few points (below the scan's + minimum of 20). A smaller piezo range pulls the corners into the circle.""" + from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import ( + lamni_smallest_tile_position_count, + ) + + center, _ = lamni_smallest_tile_position_count(20, 20, 1.0, 0, 0, 0.2, 36) + corner, tile = lamni_smallest_tile_position_count(20, 20, 1.0, 2, 2, 0.2, 36) + smaller_piezo, _ = lamni_smallest_tile_position_count(10, 10, 1.0, 2, 2, 0.2, 36) + + assert center > 100 + assert corner < 20 + assert abs(tile[0]) == 1 and abs(tile[1]) == 1 + assert smaller_piezo >= 20