diff --git a/.github/workflows/pr-version-check.yml b/.github/workflows/pr-version-check.yml new file mode 100644 index 0000000..510bbbc --- /dev/null +++ b/.github/workflows/pr-version-check.yml @@ -0,0 +1,66 @@ +name: Main Branch Pull Request Version Check + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + version-check: + name: Check version bump + runs-on: ubuntu-latest + steps: + - name: Checkout PR branch + uses: actions/checkout@v7 + + - name: Read PR version + id: pr_version + run: | + VERSION=$(grep -m1 '^version *= *' pyproject.toml | sed -E 's/version *= *"([^"]+)"/\1/') + if [ -z "$VERSION" ]; then + echo "Could not read version from pyproject.toml" >&2 + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Read main version + id: main_version + run: | + git fetch origin main --depth=1 + VERSION=$(git show origin/main:pyproject.toml | grep -m1 '^version *= *' | sed -E 's/version *= *"([^"]+)"/\1/') + if [ -z "$VERSION" ]; then + echo "Could not read version from main's pyproject.toml" >&2 + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Install uv + uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d + + - name: Compare versions + run: | + PR_VERSION="${{ steps.pr_version.outputs.version }}" + MAIN_VERSION="${{ steps.main_version.outputs.version }}" + echo "PR version: $PR_VERSION" + echo "main version: $MAIN_VERSION" + uv run --with packaging python3 - "$PR_VERSION" "$MAIN_VERSION" <<'EOF' + import sys + from packaging.version import Version, InvalidVersion + + pr_raw, main_raw = sys.argv[1], sys.argv[2] + + try: + pr = Version(pr_raw) + main = Version(main_raw) + except InvalidVersion as e: + print(f"Could not parse version as PEP 440: {e}", file=sys.stderr) + sys.exit(1) + + if pr <= main: + print(f'Version in pyproject.toml ({pr_raw}) is not greater than main ({main_raw}). Versions must strictly increase.', file=sys.stderr) + sys.exit(1) + + print(f'OK: {pr_raw} > {main_raw}') + EOF diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 0000000..afeb963 --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,40 @@ +name: Create PyLisC Release + +on: + push: + branches: [main] + +permissions: + contents: write + +jobs: + release: + name: Build and publish release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Install uv + uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d + + - name: Read version + id: version + run: | + VERSION=$(grep -m1 '^version *= *' pyproject.toml | sed -E 's/version *= *"([^"]+)"/\1/') + if [ -z "$VERSION" ]; then + echo "Could not read version from pyproject.toml" >&2 + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Build package + run: uv build + + - name: Publish release + uses: softprops/action-gh-release@v3 + with: + tag_name: v${{ steps.version.outputs.version }} + name: v${{ steps.version.outputs.version }} + generate_release_notes: true + draft: false + files: dist/* \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index cafe3b1..a0492d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pylisc" -version = "2.0.1" +version = "2.1.0" description = "Python implementation of LisC algorithm" readme = "README.md" authors = [ diff --git a/src/pylisc/cli.py b/src/pylisc/cli.py index e6932ac..9d99409 100644 --- a/src/pylisc/cli.py +++ b/src/pylisc/cli.py @@ -43,7 +43,7 @@ def version_callback(value: bool | None) -> None: ] VerbosityOpt = Annotated[ int, - typer.Option('-v', '--verbose', count=True, help='Increase verbosity of logging.'), + typer.Option('-v', '--verbose', count=True, help='Increase verbosity of logging.', show_default=False, metavar=''), ] ApplyFilterOpt = Annotated[ bool, @@ -73,6 +73,18 @@ def version_callback(value: bool | None) -> None: float, typer.Option('--angle-outlier-threshold', help='Warn if an individual angle estimate differs from its consensus by more than this many degrees.', rich_help_panel='Batch options'), ] +ForceOpt = Annotated[ + bool, + typer.Option('--force', help='Overwrite existing output files.', show_default=False) +] +DryRunOpt = Annotated[ + bool, + typer.Option('--dry-run', help='Print what would be processed/written without writing any output.', show_default=False), +] +WorkersOpt = Annotated[ + int, + typer.Option('--workers', help='Number of parallel processes to use in batch mode (0: all CPUs).', min=0, rich_help_panel='Batch options'), +] # Define callback for pylisc (to allow version option) @pylisc.callback() @@ -97,6 +109,8 @@ def stack( typer.Option('--output-dir', help='Output directory for batch mode (required when input_path is a directory).', rich_help_panel='Batch options') ] = None, mode: ModeOpt = 'angular', + dry_run: DryRunOpt = False, + force: ForceOpt = False, verbosity: VerbosityOpt = 0, apply_filter: ApplyFilterOpt = False, filter_threshold: FilterThresholdOpt = 5000.0, @@ -118,27 +132,30 @@ def stack( dc_protect_frac: DcProtectFracOpt = 0.01, angle_outlier_threshold: AngleOutlierThresholdOpt = 5.0, version: VersionOpt = None, + workers: WorkersOpt = 0, ): ''' Destripe a single MRC tilt-series stack, or a directory of them. ''' + verbosity = 2 if verbosity >= 2 else verbosity + configure_logger(input_path.is_dir(), input_path, output_mrc, output_dir, verbosity) + logger.debug('running pylisc stack with: {}', ', '.join(f'{i[0]}: {i[1]}' for i in locals().items())) + + if angular_width <= 0: + logger.error('angular width cannot be equal to or less than 0: {}', angular_width) + raise typer.BadParameter(f'angular width cannot be equal to or less than 0: {angular_width}') + if input_path.is_dir(): if output_dir is None: raise typer.BadParameter('--output-dir is required when input_path is a directory') if output_mrc is not None: - print(f'Ignoring argument: {output_mrc} (output filename)') + logger.warning('[batch mode] ignoring argument: {} (output filename)', output_mrc) if preview_strengths is not None: - print(f'Ignoring option: --preview-strengths {preview_strengths}') - is_dir = True + logger.warning('[batch mode] ignoring option: --preview-strengths {}', preview_strengths) else: if output_dir is not None: - print(f'Ignoring option: --output-dir {output_dir}') - is_dir = False - verbosity = 2 if verbosity >= 2 else verbosity - - configure_logger(is_dir, input_path, output_mrc, output_dir, verbosity) - logger.debug('running pylisc stack with: {}', ', '.join(f'{i[0]}: {i[1]}' for i in locals().items())) - + logger.warning('[single file mode] ignoring option: --output-dir {}', output_dir) + if mode == 'linear': logger.warning('Linear destriping mode is deprecated and may be removed in future updates. Angular destriping is more effective and is the recommended destriping mode.') @@ -157,6 +174,9 @@ def stack( dc_protect_frac=dc_protect_frac, angle_outlier_threshold=angle_outlier_threshold, preview_strengths=preview_strengths, + force=force, + dry_run=dry_run, + workers=workers, ) logger.info('pylisc completed') raise typer.Exit() @@ -181,6 +201,8 @@ def frames( typer.Option('--filename-delimiters', help='Characters that separate filename fields, see README for further information.') ] = '_', mode: ModeOpt = 'angular', + dry_run: DryRunOpt = False, + force: ForceOpt = False, verbosity: VerbosityOpt = 0, apply_filter: ApplyFilterOpt = False, filter_threshold: FilterThresholdOpt = 5000.0, @@ -194,6 +216,7 @@ def frames( dc_protect_frac: DcProtectFracOpt = 0.01, angle_outlier_threshold: AngleOutlierThresholdOpt = 5.0, version: VersionOpt = None, + workers: WorkersOpt = 0, ): ''' Destripe a directory of 2D MRC frames. @@ -203,6 +226,10 @@ def frames( configure_logger(True, input_path, None, output_dir, verbosity) logger.debug('running pylisc frames with: {}', ', '.join(f'{i[0]}: {i[1]}' for i in locals().items())) + if angular_width <= 0: + logger.error('angular width cannot be equal to or less than 0: {}', angular_width) + raise typer.BadParameter(f'angular width cannot be equal to or less than 0: {angular_width}') + if mode == 'linear': logger.warning('Linear destriping mode is deprecated and may be removed in future updates. Angular destriping is more effective and is the recommended destriping mode.') @@ -220,6 +247,9 @@ def frames( notch_frac=notch_frac, dc_protect_frac=dc_protect_frac, angle_outlier_threshold=angle_outlier_threshold, + force=force, + dry_run=dry_run, + workers=workers, ) logger.info('pylisc completed') raise typer.Exit() diff --git a/src/pylisc/frames.py b/src/pylisc/frames.py index edc51c2..3d5c3f0 100644 --- a/src/pylisc/frames.py +++ b/src/pylisc/frames.py @@ -3,7 +3,8 @@ ''' # Import external libraries -import numpy as np, typer +import numpy as np, os, typer +from concurrent.futures import ProcessPoolExecutor, as_completed # Import internal PyLisC modules from pylisc.estimate_angle import combine_angles, estimate_curtain_angle @@ -27,6 +28,9 @@ def run_frames( notch_frac, dc_protect_frac, angle_outlier_threshold, + force, + dry_run, + workers, ): if apply_filter and pixel_size is None: raise typer.BadParameter('--pixel-size is required when --apply-filter is set in frames mode (frame headers are not used for pixel size)') @@ -39,37 +43,92 @@ def run_frames( pattern = compile_template(filename_template, delimiters=filename_delimiters) tilt_of = {path: extract_tilt_angle(path.name, pattern) for path in paths} - output_dir.mkdir(parents=True, exist_ok=True) - if curtain_angle is None: angle_for_path = _estimate_per_tilt_angles(paths, tilt_of, angle_outlier_threshold) else: angle_for_path = {path: curtain_angle for path in paths} + jobs = [] for path in paths: - out_path = output_dir / f'{path.stem}_PyLisC_{mode}.mrc' - with per_file_log(output_dir, out_path.stem): - data, _ = readMrcFile(path) + relative = path.relative_to(input_dir) + out_path = output_dir / relative.parent / f'{path.stem}_PyLisC_{mode}.mrc' + out_path.parent.mkdir(parents=True, exist_ok=True) + jobs.append((path, out_path, angle_for_path[path])) + + max_workers = os.cpu_count() + workers = max_workers if workers == 0 else min(workers, max_workers) + + logger.info('processing {} files across {} workers', len(jobs), workers) + if dry_run: + for path, out_path, _ in jobs: + logger.info('[dry-run] ({}) would write {}', path.name, out_path) + return + + with ProcessPoolExecutor(max_workers=workers) as pool: + futures = { + pool.submit( + _process_one, + path, + out_path, + angle, + mode, + pixel_size, + apply_filter, + filter_threshold, + angular_width, + notch_frac, + dc_protect_frac, + force, + ): path + for path, out_path, angle in jobs + } + for future in as_completed(futures): + path = futures[future] + try: + future.result() + except Exception as e: + logger.error('({}) failed during batch processing: {}', path.name, e) + logger.debug('({}) traceback:', path.name, exc_info=e) + continue + logger.debug('({}) done', path.name) + + logger.info('cleared mrc files written to {}', output_dir) + + +def _process_one( + path, + out_path, + curtain_angle, + mode, + pixel_size, + apply_filter, + filter_threshold, + angular_width, + notch_frac, + dc_protect_frac, + force, +): + with per_file_log(out_path.parent, out_path.stem): + logger.debug('({}) starting destriping', path.name) + try: + data, voxel_size = readMrcFile(path) cleared = lisc_clear_frame( data[0], decurtaining_mode=mode, pixel_size_nm=pixel_size, - curtain_angle=angle_for_path[path], + curtain_angle=curtain_angle, apply_filter=apply_filter, filter_threshold_nm=filter_threshold, angular_width_deg=angular_width, destripe_notch_fraction=notch_frac, dc_protect_frac=dc_protect_frac, ) - writeMrcFile(cleared[np.newaxis, ...], _read_voxel_size(path), out_path) + writeMrcFile(cleared[np.newaxis, ...], voxel_size, out_path, force) logger.debug('({}) cleared mrc file wrote to {}', path.name, out_path) - - logger.info('cleared mrc files written to {}', output_dir) - - -def _read_voxel_size(path): - _, voxel_size = readMrcFile(path) - return voxel_size + except Exception as e: + logger.error('({}) failed: {}', path.name, e) + logger.debug('({}) traceback: {}', path.name, exc_info=e) + raise def _estimate_per_tilt_angles(paths, tilt_of, angle_outlier_threshold): @@ -106,4 +165,4 @@ def _estimate_per_tilt_angles(paths, tilt_of, angle_outlier_threshold): if deviation > angle_outlier_threshold: logger.warning('tilt {}° consensus angle ({}°) deviates {}° from overall consensus ({}°) - check per-tilt agreement', bucket, f'{angle:.1f}', f'{deviation:.1f}', f'{overall_consensus:.1f}') - return {path: bucket_consensus[round(tilt_of[path])] for path in paths} + return {path: bucket_consensus[round(tilt_of[path])] for path in paths} \ No newline at end of file diff --git a/src/pylisc/io.py b/src/pylisc/io.py index ca9e205..2b851dc 100644 --- a/src/pylisc/io.py +++ b/src/pylisc/io.py @@ -27,7 +27,9 @@ def readMrcFile(path: Path): data = data[np.newaxis, ...] return data, voxel_size -def writeMrcFile(data, voxel_size, path: Path): +def writeMrcFile(data, voxel_size, path: Path, force: bool = False): + if path.exists() and not force: + raise FileExistsError(f'{path} already exists: use --force to overwrite') with mrcfile.new(path, overwrite=True) as out: out.set_data(data.astype(np.float32)) out.voxel_size = voxel_size diff --git a/src/pylisc/log.py b/src/pylisc/log.py index 48f6597..b8b036b 100644 --- a/src/pylisc/log.py +++ b/src/pylisc/log.py @@ -25,8 +25,8 @@ def configure_logger(batch_mode, input_path, output_mrc, output_dir, verbosity): # Set up logger logger.remove() # remove default handler - logger.add(sys.stderr, format=LOG_FORMAT, level=log_level, colorize=True) # add terminal logger - logger.add(log_path, format=LOG_FORMAT, level=log_level) # add file logger + logger.add(sys.stderr, format=LOG_FORMAT, level=log_level, colorize=True, enqueue=True) # add terminal logger + logger.add(log_path, format=LOG_FORMAT, level=log_level, enqueue=True) # add file logger # Logging confirmation logger.debug('logging configured: stderr and {}, level={}', log_path, log_level) diff --git a/src/pylisc/stack.py b/src/pylisc/stack.py index 32a3964..238fd68 100644 --- a/src/pylisc/stack.py +++ b/src/pylisc/stack.py @@ -3,7 +3,8 @@ ''' # Import external libraries -import numpy as np, typer +import numpy as np, os, typer +from concurrent.futures import ProcessPoolExecutor, as_completed from pathlib import Path # Import internal PyLisC modules @@ -39,9 +40,16 @@ def _process_series( angular_width, notch_frac, dc_protect_frac, + force, + dry_run, preview_strengths=None, ): with per_file_log(out_path.parent, out_path.stem): + if out_path.exists() and not force: + logger.error('({}) output {} already exists: use --force to overwrite', path.name, out_path) + raise FileExistsError(f'{out_path} already exists: use --force to overwrite') + + logger.debug('({}) started processing', path.name) data, voxel_size = readMrcFile(path) # Resolve pixel size @@ -53,23 +61,24 @@ def _process_series( if resolved_pixel_size <= 0: raise ValueError('Pixel size cannot be less than or equal to 0') - # Resolve reference frame (use mid-frame as should be ok for both dose-symmetric & continuous acquisitions) - if reference_frame is None: - resolved_reference_frame = len(data) // 2 - logger.debug('({}) reference_frame defaulted to: {}', path.name, resolved_reference_frame) - else: - resolved_reference_frame = reference_frame + # Resolve reference frame + resolved_reference_frame = _resolve_reference_frame(path, data, reference_frame) # Estimate curtaining angle if not provided if curtain_angle is None: resolved_angle, angular_energy = estimate_curtain_angle(data[resolved_reference_frame]) - plot_angular_energy(angular_energy, resolved_angle, output_dir=out_path.parent) + if not dry_run: + plot_angular_energy(angular_energy, resolved_angle, output_dir=out_path.parent) median_energy = np.median(angular_energy) confidence = angular_energy.max() / median_energy if median_energy > 0 else 0.0 logger.info('({}) estimated curtaining angle: {}° (confidence: {})', path.name, resolved_angle, confidence) else: resolved_angle = curtain_angle + if dry_run: + logger.info('({}) [dry-run] would write to {} (angle: {}°, pixel size: {})', path.name, out_path, resolved_angle, resolved_pixel_size) + return resolved_angle + if preview_strengths is not None: values = [float(v) for v in preview_strengths.split(',')] logger.info('previewing {} destriping strength(s)', len(values)) @@ -103,7 +112,7 @@ def _process_series( dc_protect_frac=dc_protect_frac, ) - writeMrcFile(cleared_stack, voxel_size, out_path) + writeMrcFile(cleared_stack, voxel_size, out_path, force) logger.debug('({}) cleared mrc file wrote to {}', path.name, out_path) return resolved_angle @@ -122,6 +131,9 @@ def run_stack( notch_frac, dc_protect_frac, angle_outlier_threshold, + force, + dry_run, + workers, preview_strengths=None, ): if input_path.is_dir(): @@ -138,6 +150,9 @@ def run_stack( notch_frac=notch_frac, dc_protect_frac=dc_protect_frac, angle_outlier_threshold=angle_outlier_threshold, + force=force, + dry_run=dry_run, + workers=workers, ) else: out_path = output_mrc if output_mrc is not None else _default_output_path(input_path, mode) @@ -154,6 +169,8 @@ def run_stack( notch_frac=notch_frac, dc_protect_frac=dc_protect_frac, preview_strengths=preview_strengths, + force=force, + dry_run=dry_run, ) @@ -170,6 +187,9 @@ def _run_stack_batch( notch_frac, dc_protect_frac, angle_outlier_threshold, + force, + dry_run, + workers, ): series_paths = find_input_files(input_dir, recursive=True) if not series_paths: @@ -180,7 +200,7 @@ def _run_stack_batch( angles, confidences = [], [] for path in series_paths: data, _ = readMrcFile(path) - frame_index = reference_frame if reference_frame is not None else len(data) // 2 + frame_index = _resolve_reference_frame(path, data, reference_frame) frame = data[frame_index] angle, energy = estimate_curtain_angle(frame) median_energy = np.median(energy) @@ -197,22 +217,57 @@ def _run_stack_batch( logger.warning('({}) est. angle ({}°) deviates {}° from consensus ({}°) - check diagnostic plot', path.name, f'{angle:.1f}', f'{deviation:.1f}', f'{consensus_angle:.1f}') curtain_angle = consensus_angle - + + jobs = [] for path in series_paths: relative = path.relative_to(input_dir) out_path = output_dir / relative.parent / f'{path.stem}_PyLisC_{mode}.mrc' out_path.parent.mkdir(parents=True, exist_ok=True) - _process_series( - path, - out_path, - mode=mode, - apply_filter=apply_filter, - filter_threshold=filter_threshold, - pixel_size=pixel_size, - curtain_angle=curtain_angle, - reference_frame=reference_frame, - angular_width=angular_width, - notch_frac=notch_frac, - dc_protect_frac=dc_protect_frac, - ) + jobs.append((path, out_path)) + + max_workers = os.cpu_count() + workers = max_workers if workers == 0 else min(workers, max_workers) + + logger.info('processing {} files across {} workers', len(jobs), workers) + with ProcessPoolExecutor(max_workers=workers) as pool: + futures = { + pool.submit( + _process_series, + path, + out_path, + mode=mode, + apply_filter=apply_filter, + filter_threshold=filter_threshold, + pixel_size=pixel_size, + curtain_angle=curtain_angle, + reference_frame=reference_frame, + angular_width=angular_width, + notch_frac=notch_frac, + dc_protect_frac=dc_protect_frac, + force=force, + dry_run=dry_run, + ): path + for path, out_path in jobs + } + for future in as_completed(futures): + path = futures[future] + try: + future.result() + except Exception as e: + logger.error('({}) failed during batch processing: {}', path.name, e) + logger.debug('({}) traceback:', path.name, exc_info=e) logger.info('cleared mrc files written to {}', output_dir) + +# -- _resolve_reference_frame: return the resolved reference frame, defaulting to mid-frame is no value provided or value is invalid +def _resolve_reference_frame(path, data, reference_frame): + # Resolve reference frame (use mid-frame as should be ok for both dose-symmetric & continuous acquisitions) + if reference_frame is None: + resolved_reference_frame = len(data) // 2 + logger.debug('({}) reference_frame defaulted to: {}', path.name, resolved_reference_frame) + elif reference_frame >= len(data): + logger.warning('({}) supplied reference frame index ({}) does not exist - defaulting to mid-frame', path.name, reference_frame) + resolved_reference_frame = len(data) // 2 + logger.debug('({}) reference_frame defaulted to: {}', path.name, resolved_reference_frame) + else: + resolved_reference_frame = reference_frame + return resolved_reference_frame \ No newline at end of file diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py index 4c7ec34..ee406c0 100644 --- a/tests/integration/test_cli.py +++ b/tests/integration/test_cli.py @@ -1,3 +1,8 @@ +''' +PyLisC: integration tests for PyLisC CLI +''' + +# Import external libraries from typer.testing import CliRunner from pylisc.cli import pylisc @@ -14,6 +19,43 @@ def test_single_file_run(self, tmp_path): assert result.exit_code == 0 assert (tmp_path / 'series_PyLisC_angular.mrc').exists() assert (tmp_path / 'series_PyLisC_angular.log').exists() + + def test_rerun_without_force_fails_then_succeeds_with_force(self, tmp_path): + from tests.fixtures import synthetic_tilt_series, write_synthetic_mrc + input_path = tmp_path / 'series.mrc' + write_synthetic_mrc(input_path, synthetic_tilt_series(n_tilts=3, angle_deg=20)) + out_path = tmp_path / 'series_PyLisC_angular.mrc' + + first = runner.invoke(pylisc, ['stack', str(input_path), '--mode', 'angular']) + assert first.exit_code == 0 + original_mtime = out_path.stat().st_mtime + + rerun = runner.invoke(pylisc, ['stack', str(input_path), str(out_path), '--mode', 'angular']) + assert rerun.exit_code != 0 + assert out_path.stat().st_mtime == original_mtime # untouched + + forced = runner.invoke(pylisc, ['stack', str(input_path), str(out_path), '--mode', 'angular', '--force']) + assert forced.exit_code == 0 + + def test_dry_run_writes_nothing(self, tmp_path): + from tests.fixtures import synthetic_tilt_series, write_synthetic_mrc + input_path = tmp_path / 'series.mrc' + write_synthetic_mrc(input_path, synthetic_tilt_series(n_tilts=3, angle_deg=20)) + + result = runner.invoke(pylisc, ['stack', str(input_path), '--mode', 'angular', '--dry-run']) + assert result.exit_code == 0 + assert not (tmp_path / 'series_PyLisC_angular.mrc').exists() + + def test_reference_frame_at_series_length_is_rejected_cleanly(self, tmp_path): + from tests.fixtures import synthetic_tilt_series, write_synthetic_mrc + input_path = tmp_path / 'series.mrc' + write_synthetic_mrc(input_path, synthetic_tilt_series(n_tilts=3, angle_deg=20)) + + result = runner.invoke(pylisc, [ + 'stack', str(input_path), '--mode', 'angular', '--reference-frame', '3', + ]) + assert result.exit_code == 0 + assert 'IndexError' not in result.output class TestCliBatch: def test_batch_run_with_outlier_warning(self, tmp_path): @@ -32,7 +74,22 @@ def test_batch_run_with_outlier_warning(self, tmp_path): assert (output_dir / 'a_PyLisC_angular.mrc').exists() assert (output_dir / 'a_PyLisC_angular.log').exists() -class TestCliPreview: + def test_batch_reference_frame_out_of_range_does_not_crash_whole_batch(self, tmp_path): + from tests.fixtures import synthetic_tilt_series, write_synthetic_mrc + input_dir = tmp_path / 'raw' + input_dir.mkdir() + output_dir = tmp_path / 'cleared' + write_synthetic_mrc(input_dir / 'a.mrc', synthetic_tilt_series(n_tilts=5, angle_deg=20)) + write_synthetic_mrc(input_dir / 'short.mrc', synthetic_tilt_series(n_tilts=2, angle_deg=20)) + + result = runner.invoke(pylisc, [ + 'stack', str(input_dir), '--mode', 'angular', + '--output-dir', str(output_dir), '--reference-frame', '4', + ]) + assert result.exit_code == 0 + assert (output_dir / 'a_PyLisC_angular.mrc').exists() + +class TestCliOptions: def test_preview_exits_without_full_run(self, tmp_path): from tests.fixtures import synthetic_tilt_series, write_synthetic_mrc stack = synthetic_tilt_series(n_tilts=3) @@ -44,6 +101,16 @@ def test_preview_exits_without_full_run(self, tmp_path): assert (tmp_path / 'destripe_strength_preview.tiff').exists() assert not (tmp_path / 'series_PyLisC_angular.mrc').exists() # full run did NOT happen + def test_zero_angular_width_is_rejected(self, tmp_path): + from tests.fixtures import synthetic_tilt_series, write_synthetic_mrc + input_path = tmp_path / 'series.mrc' + write_synthetic_mrc(input_path, synthetic_tilt_series(n_tilts=3, angle_deg=20)) + + result = runner.invoke(pylisc, [ + 'stack', str(input_path), '--mode', 'angular', '--angular-width', '0', + ]) + assert result.exit_code != 0 + class TestCliFrames: def test_frames_run_groups_by_tilt_and_warns_on_outlier(self, tmp_path): from tests.fixtures import write_synthetic_frame diff --git a/tests/unit/test_destripe.py b/tests/unit/test_destripe.py index e01fd7a..8d15869 100644 --- a/tests/unit/test_destripe.py +++ b/tests/unit/test_destripe.py @@ -30,4 +30,14 @@ def test_linear_mode_isotropic_loss_without_dc_protect(self): rng = np.random.default_rng(5) large_scale = (ndi.gaussian_filter(rng.normal(0, 1, (1024, 1024)), sigma=60) * 200).astype('float32') kept = self.retained_fraction(large_scale, -65, directional_destripe_linear, notch_frac=0.02, dc_protect_frac=0.0) - assert kept < 0.2 # regression guard for the isotropic-high-pass bug we found and fixed \ No newline at end of file + assert kept < 0.2 + + def test_zero_angular_width_does_not_produce_nan(self): + import numpy as np + from pylisc.destripe import directional_destripe_angular + from tests.fixtures import synthetic_frame + frame = synthetic_frame(size=64, angle_deg=20) + + result = directional_destripe_angular(frame, angle_deg=20, angular_width_deg=0) + + assert not np.isnan(result).any() \ No newline at end of file diff --git a/tests/unit/test_io.py b/tests/unit/test_io.py new file mode 100644 index 0000000..3377e09 --- /dev/null +++ b/tests/unit/test_io.py @@ -0,0 +1,48 @@ +''' +PyLisC: unit tests for input/output utilities +''' + +# -- Import external libraries +import numpy as np, pytest + +# -- Import internal functions +from pylisc.io import readMrcFile, writeMrcFile +from pylisc.stack import _default_output_path + +class TestIo: + def test_default_output_path_appends_numeric_suffix_on_collision(self, tmp_path): + input_path = tmp_path / 'series.mrc' + input_path.touch() + (tmp_path / 'series_PyLisC_angular.mrc').touch() # first choice taken + (tmp_path / 'series_PyLisC_angular_1.mrc').touch() # second choice also taken + + out_path = _default_output_path(input_path, mode='angular') + + assert out_path == tmp_path / 'series_PyLisC_angular_2.mrc' + + def test_write_then_read_roundtrip(self, tmp_path): + from tests.fixtures import synthetic_frame + path = tmp_path / 'out.mrc' + frame = synthetic_frame(size=64)[np.newaxis, ...] + + writeMrcFile(frame, 34.0, path) # voxel_size in Angstrom + data, voxel_size = readMrcFile(path) + + assert data.shape == frame.shape + np.testing.assert_allclose(data, frame, rtol=1e-5) + assert float(voxel_size.x) == pytest.approx(34.0) + + def test_write_refuses_existing_file_without_force(self, tmp_path): + path = tmp_path / 'out.mrc' + path.write_bytes(b'not really an mrc, just occupying the path') + + with pytest.raises(FileExistsError): + writeMrcFile(np.zeros((1, 4, 4), dtype=np.float32), 10.0, path) + + def test_write_overwrites_existing_file_with_force(self, tmp_path): + path = tmp_path / 'out.mrc' + path.write_bytes(b'not really an mrc, just occupying the path') + + writeMrcFile(np.zeros((1, 4, 4), dtype=np.float32), 10.0, path, force=True) + data, _ = readMrcFile(path) + assert data.shape == (1, 4, 4) diff --git a/uv.lock b/uv.lock index 3f3e231..6210dbb 100644 --- a/uv.lock +++ b/uv.lock @@ -321,7 +321,7 @@ wheels = [ [[package]] name = "pylisc" -version = "2.0.1" +version = "2.1.0" source = { editable = "." } dependencies = [ { name = "loguru" },