diff --git a/.gitignore b/.gitignore index b75da96..a40ae92 100644 --- a/.gitignore +++ b/.gitignore @@ -9,5 +9,9 @@ dist/ wheels/ *.egg-info +# Pytest files +.coverage +.pytest_cache + # Virtual environments .venv diff --git a/README.md b/README.md index 2234cd8..6feb8df 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ pylisc --help ```sh pylisc stack [OPTIONS] INPUT_MRC [OUTPUT_MRC] ``` -`OUTPUT_MRC` is optional. If omitted, it defaults to `INPUT_MRC` with a `_PyLisC_{mode}` suffix, saved to the same directory as `INPUT_MRC`. If that file already exists, a numeric suffix is appended instead of overwriting it. +`OUTPUT_MRC` is optional. If omitted, it defaults to `INPUT_MRC` with a `_PyLisC_{mode}` suffix, saved to the same directory as `INPUT_MRC`. If that file already exists, a numeric suffix is appended instead of overwriting it (pass `--force` to overwrite in place instead). #### Options @@ -57,7 +57,7 @@ Option | Default | Description --|--|-- `-m`, `--mode` | `angular` | Destriping approach: `angular` (recommended) or `linear` (deprecated). See [Destriping mode](#destriping-mode) below. `--angle` | *(auto-estimated)* | Curtaining orientation, degrees from horizontal. Omit to estimate automatically from the tilt series' central frame[^estimation]; pass a value to override. A diagnostic plot is saved alongside the output when auto-estimated[^diagnosticplot]. -`--reference-frame` | `0` | Stack index used for angle estimation and the destriping preview. See [Choosing a reference frame](#choosing-a-reference-frame) for more information. +`--reference-frame` | *(mid-stack index)* | Stack index used for angle estimation and the destriping preview. See [Choosing a reference frame](#choosing-a-reference-frame) for more information. `--angular-width` | `8.0` | Angular width of the destriping notch, in degrees. Only used when `--mode angular`. Narrower keeps more real structure sharing a nearby angle to the curtains, at the cost of weaker curtain removal. `--notch-fraction` | `0.03` | Width of the destriping notch, as a fraction of image width. Narrower removes less real signal running parallel to the curtains, but leaves more curtaining behind. Only used when `--mode linear`, which is deprecated. `--protect-fraction` | `0.01` | Fraction of image width around the zero-frequency (DC) origin exempted from destriping. See [Destriping mode](#destriping-mode) below for why this exists and its trade-off. Only used when `--mode linear`, which is deprecated. @@ -66,7 +66,16 @@ Option | Default | Description Option | Default | Description --|--|-- `--output-dir` | *(required for directory input)* | Output directory for batch mode, mirroring the input directory's structure. -`--angle-outlier-threshold` | `5.0` | Warn if an individual series' own angle estimate differs from the batch consensus by more than this many degrees. In `frames` mode, a tilt beyond this threshold also has its angle replaced with its nearest reliable tilt's angle, see [per-tilt curtain angle](#per-tilt-curtain-angle) for details. +`--angle-outlier-threshold` | `5.0` | Warn if an individual series' own angle estimate differs from the batch consensus by more than this many degrees. +`--workers` | `0` (all CPUs) | Number of parallel processes to use in batch mode. + +##### Other options +Option | Default | Description +--|--|-- +`--force` | - | Overwrite existing output files instead of erroring or auto-numbering. +`--dry-run` | - | Print what would be processed/written, including the resolved angle and pixel size, without writing any output. +`-v`, `--verbose` | - | Increase logging verbosity (repeat for more, e.g. `-vv`). + #### Example ```sh # Run PyLisC, estimating the curtaining angle automatically @@ -89,7 +98,7 @@ Before committing to a full run, different strength values can be previewed agai pylisc stack --preview-strengths 3,5,8,12,20 tilt_series.mrc ``` -This saves `destripe_strength_preview.tiff`, a side-by-side montage labelled with each value, and exits without processing the rest of the stack. Uses `--reference-frame` (see [below](#choosing-a-reference-frame)) as the preview frame. +This saves `destripe_strength_preview.tiff`, a side-by-side montage labelled with each value, and exits without processing the rest of the stack. Uses `--reference-frame` (see [below](#choosing-a-reference-frame)) as the preview frame. Only applies in single-file mode; in batch mode it is ignored with a warning. ### Batch mode @@ -123,7 +132,12 @@ pylisc frames [OPTIONS] --output-dir OUTPUT_DIR --filename-template TEMPLATE INP This command is aimed at destriping tilt images that exist as individual 2D MRC frames, i.e. not yet assembled/aligned into a stack. `INPUT_DIR` is not recursed into; every `*.mrc` directly inside it (excluding PyLisC's own `_PyLisC_` output) is treated as one tilt image. `--output-dir` is required, and mirrors the input's flat structure: each frame is written back out individually with a `_PyLisC_{mode}` suffix, same as `--output-dir` does for [batch mode](#batch-mode). #### Options -`pylisc frames` uses many of the same options as `pylisc stack`, see [above](#options) or run `pylisc frames -h` for further information. +`pylisc frames` uses many of the same options as `pylisc stack`, see [above](#options) or run `pylisc frames -h` for further information. Frames mode does not take `--reference-frame` or `--preview-strengths`, but adds: + +Option | Default | Description +--|--|-- +`--anchor-tilts` | `5` | Number of tilt buckets nearest each series' median tilt used to seed its consensus walk. See [Per-tilt curtain angle](#per-tilt-curtain-angle) below. +`--print-angles` | off | Print a diagnostic table of per-file and per-bucket angle estimation, and write it to CSV files in `--output-dir`. #### Filename template Since a flat directory has no per-series subdirectory to group frames by, PyLisC needs to know which filename field is the tilt angle. This is given as a template describing the delimited filename fields, with `{}` for fields to ignore and `{tilt}` (required) for the tilt angle field, e.g. for `Position_012_003_-30.00_20240115_1_Fractions_motion_corrected.mrc`: @@ -143,11 +157,11 @@ Field boundaries default to underscore only. `--filename-delimiters` sets which #### Per-tilt curtain angle Curtaining orientation drifts slightly with tilt angle, so unless `--angle` is given explicitly, frames mode does **not** use one consensus angle for the whole directory. Instead: 1. Every frame's own angle is estimated. -2. Frames are grouped by tilt angle, rounded to the nearest whole degree (so e.g. two positions' `-30.00°` and `-29.98°` tilts fall in the same group). -3. Each frame's confidence ratio is clipped to a per-run cap before use, so a single sharp FFT peak can't dominate its group's consensus or the overall weighting below. The cap is the confidence distribution's median plus 3x its (scaled) median absolute deviation, which stays robust even with few frames (falling back to 5x the median when every value is identical, i.e. zero deviation). -4. Each group's estimates are combined into a per-tilt consensus (same confidence-weighted circular mean as [batch mode](#shared-curtain-angle)), using the clipped confidences, which is the angle applied to every frame in that group. -5. All per-tilt consensus angles are then combined into an overall consensus, weighted both by the group's typical (median) confidence and by `cos(tilt)` (sample thickness grows ~1/cos(tilt) so high tilt angles are less reliable), so they count for less than well-sampled low-tilt groups rather than skewing the overall consensus by an equal vote. -6. Any per-tilt consensus that still deviates from the overall consensus by more than `--angle-outlier-threshold` is treated as unreliable, and will be destriped at the consensus angle of its nearest reliable tilt (by tilt-angle distance) instead, logging a warning naming both tilts. If every tilt ends up flagged, PyLisC falls back to the overall consensus for all of them. +2. Each frame's confidence ratio is clipped to a per-run cap before use, so a single sharp FFT peak can't dominate its bucket's consensus. The cap is the confidence distribution's median plus 3x its (scaled) median absolute deviation, falling back to 5x the median when every value is identical, (i.e. zero deviation). +3. Frames are grouped by tilt angle, rounded to the nearest whole degree (so e.g. two positions' `-30.00°` and `-29.98°` tilts fall in the same bucket), and each bucket's estimates are combined into a per-tilt consensus (the same confidence-weighted circular mean as [batch mode](#shared-curtain-angle)), using the clipped confidences. +4. Tilt buckets are clustered into separate acquisition series (by matching frame count per bucket, then splitting wherever the tilt spacing breaks step), so a directory holding more than one tilt series is handled independently per series rather than as one pool. +5. Within each series, the `--anchor-tilts` buckets around the series' median tilt are used as a trusted seed. PyLisC then walks outward from this seed, and compares each tilt bucket's own consensus to the nearest already-resolved bucket. A tilt bucket's consensus angle is accepted (becoming the new "nearest resolved" point) if it's within `--angle-outlier-threshold` degrees of it, otherwise it is replaced with that nearest resolved angle and logged as a warning naming both tilts. This approach prevents buckets with incorrect angles from throwing off subsequent tilts, while allowing for slight angle drift over a tilt series. +6. Pass `--print-angles` to see the full per-file and per-bucket breakdown (angle, confidence, resolved angle, seed/accepted/rejected status) as tables and CSV files written to `--output-dir`. #### Pixel size Individual frame MRCs frequently lack a reliable pixel size in their header, so frames mode does not fall back to it. Pixel size is only needed for the optional high-pass filter — if `--apply-filter` is set, `--pixel-size` must be given explicitly, or PyLisC exits with an error. @@ -184,7 +198,7 @@ The reference frame should be the tilt with the least foreshortening and the bes - **Dose-symmetric schemes** (0° acquired first, then alternating ±): use `--reference-frame 0`. - **Continuous sweeps** (most-negative tilt acquired first): 0° sits in the middle of the stack, so use roughly `--reference-frame ` for an n-tilt series. -By default, the reference frame is taken as the middle of the stack, under the assumption that this will be correct for continous sweeps and workable for dose-symmetric schemes (vs using the first frame which would be correct for dose-symmetric but an extreme tilt angle for continous). +By default, the reference frame is taken as the middle of the stack, under the assumption that this will be correct for continous sweeps and workable for dose-symmetric schemes (vs using the first frame which would be correct for dose-symmetric but an extreme tilt angle for continuous). An out-of-range `--reference-frame` falls back to this same default, with a warning. ### Limitations - **Directional filering:** Any real structure running parallel to the curtains shares the same Fourier orientation and is attenuated along with them, in both destriping modes. A narrower `--angular-width` (or `--notch-fraction` in linear mode) limits this but cannot eliminate it where curtains and genuine structure share an angle. Some loss of signal is likely to be observed for these structures. diff --git a/pyproject.toml b/pyproject.toml index ca8fb5f..2f248ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pylisc" -version = "2.3.0" +version = "2.4.0" description = "Python implementation of LisC algorithm" readme = "README.md" authors = [ @@ -12,6 +12,7 @@ dependencies = [ "matplotlib>=3.11.0", "mrcfile>=1.5.4", "numpy>=2.5.1", + "rich>=15.0.0", "scipy>=1.18.0", "tifffile>=2026.7.14", "typer>=0.27.0", @@ -27,4 +28,8 @@ build-backend = "uv_build" [dependency-groups] dev = [ "pytest>=9.1.1", + "pytest-cov>=7.1.0", ] + +[tool.pytest.ini_options] +addopts = "--cov --cov-report=term-missing" diff --git a/src/pylisc/cli.py b/src/pylisc/cli.py index 9d99409..bc6c621 100644 --- a/src/pylisc/cli.py +++ b/src/pylisc/cli.py @@ -85,6 +85,14 @@ def version_callback(value: bool | None) -> None: int, typer.Option('--workers', help='Number of parallel processes to use in batch mode (0: all CPUs).', min=0, rich_help_panel='Batch options'), ] +PrintAnglesOpt = Annotated[ + bool, + typer.Option('--print-angles', help='Print a diagnostic table of per-file and per-bucket angle estimation (frames mode only).', rich_help_panel='Batch options', show_default=False), +] +AnchorTiltsOpt = Annotated[ + int, + typer.Option('--anchor-tilts', help='Number of tilt buckets nearest each series\' median tilt used to seed its consensus walk (frames mode only).', rich_help_panel='Batch options', min=1), +] # Define callback for pylisc (to allow version option) @pylisc.callback() @@ -215,6 +223,8 @@ def frames( notch_frac: NotchFracOpt = 0.03, dc_protect_frac: DcProtectFracOpt = 0.01, angle_outlier_threshold: AngleOutlierThresholdOpt = 5.0, + anchor_tilts: AnchorTiltsOpt = 5, + print_angles: PrintAnglesOpt = False, version: VersionOpt = None, workers: WorkersOpt = 0, ): @@ -247,9 +257,11 @@ def frames( notch_frac=notch_frac, dc_protect_frac=dc_protect_frac, angle_outlier_threshold=angle_outlier_threshold, + anchor_tilts=anchor_tilts, force=force, dry_run=dry_run, workers=workers, + print_angles=print_angles, ) logger.info('pylisc completed') raise typer.Exit() diff --git a/src/pylisc/frames.py b/src/pylisc/frames.py index 31da8d5..692f609 100644 --- a/src/pylisc/frames.py +++ b/src/pylisc/frames.py @@ -3,8 +3,10 @@ ''' # Import external libraries -import numpy as np, os, typer +import csv, numpy as np, os, typer from concurrent.futures import ProcessPoolExecutor, as_completed +from rich.console import Console +from rich.table import Table # Import internal PyLisC modules from pylisc.estimate_angle import combine_angles, estimate_curtain_angle @@ -13,6 +15,8 @@ from pylisc.log import logger, per_file_log from pylisc.templates import compile_template, extract_tilt_angle +# Define dictionary of status colours (used by --print-angles) +_STATUS_COLORS = {'seed': 'cyan', 'accepted': 'green', 'rejected': 'red'} def run_frames( input_dir, @@ -28,9 +32,11 @@ def run_frames( notch_frac, dc_protect_frac, angle_outlier_threshold, + anchor_tilts, force, dry_run, workers, + print_angles, ): 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)') @@ -44,7 +50,7 @@ def run_frames( tilt_of = {path: extract_tilt_angle(path.name, pattern) for path in paths} if curtain_angle is None: - angle_for_path = _estimate_per_tilt_angles(paths, tilt_of, angle_outlier_threshold) + angle_for_path = _estimate_per_tilt_angles(paths, tilt_of, angle_outlier_threshold, anchor_tilts, print_angles, output_dir) else: angle_for_path = {path: curtain_angle for path in paths} @@ -130,18 +136,100 @@ def _process_one( logger.debug('({}) traceback: {}', path.name, exc_info=e) raise +def _split_by_tilt_gap(bucket_list, tol_frac=0.5): + '''Split a sorted bucket list wherever the tilt spacing breaks from the list's own regular step (e.g. two disjoint tilt ranges that happen to share the same frame count)''' + if len(bucket_list) < 3: + return [bucket_list] + diffs = np.diff(bucket_list) + step = np.median(diffs) + runs, current = [], [bucket_list[0]] + for b, d in zip(bucket_list[1:], diffs): + if step > 0 and abs(d - step) > tol_frac * step: + runs.append(current) + current = [b] + else: + current.append(b) + runs.append(current) + return runs -def _estimate_per_tilt_angles(paths, tilt_of, angle_outlier_threshold): +def _split_series(tilt_buckets, tol_frac=0.5): + ''' + Cluster tilt buckets into acquisition series by: + 1. Group by frame count + 2. Split each group where tilt spacing does not have a regular step + ''' + buckets_sorted = sorted(tilt_buckets) + n_by_bucket = {b: len(tilt_buckets[b]) for b in buckets_sorted} + distinct_n = sorted(set(n_by_bucket.values())) + n_groups = [[distinct_n[0]]] + for n in distinct_n[1:]: + if n - n_groups[-1][-1] <= 1: + n_groups[-1].append(n) + else: + n_groups.append([n]) + series = [] + for group in n_groups: + cluster = sorted(b for b in buckets_sorted if n_by_bucket[b] in group) + series.extend(_split_by_tilt_gap(cluster, tol_frac)) + return series + +def _resolve_series(series_buckets, bucket_consensus, angle_outlier_threshold, anchor_tilts) -> tuple[dict, dict]: + ''' + Seed buckets around the median tilt, and walk out, checking each against the nearest already-resolved (seed/accepted) bucket + ''' + buckets_sorted = sorted(series_buckets) + if len(buckets_sorted) == 1: + b = buckets_sorted[0] + return {b: bucket_consensus[b]}, {b: 'seed'} + + median_tilt = np.median(buckets_sorted) + center_idx = int(np.argmin([abs(b - median_tilt) for b in buckets_sorted])) + window = max(1, anchor_tilts) + half = window // 2 + start = max(0, center_idx - half) + end = min(len(buckets_sorted), start + window) + start = max(0, end - window) + + seed_buckets = buckets_sorted[start:end] + resolved = {b: bucket_consensus[b] for b in seed_buckets} + status = {b: 'seed' for b in seed_buckets} + + for direction, idx, edge in ((-1, start - 1, start), (1, end, end - 1)): + nearest = resolved[buckets_sorted[edge]] + i = idx + while 0 <= i < len(buckets_sorted): + b = buckets_sorted[i] + own_angle = bucket_consensus[b] + deviation = min(abs(own_angle - nearest), 180 - abs(own_angle - nearest)) + if deviation <= angle_outlier_threshold: + resolved[b] = own_angle + status[b] = 'accepted' + nearest = own_angle + else: + resolved[b] = nearest + status[b] = 'rejected' + logger.warning( + "tilt {}° consensus angle ({}°) deviates {}° from nearest resolved angle ({}°) - using that angle instead", + b, f'{own_angle:.1f}', f'{deviation:.1f}', f'{nearest:.1f}', + ) + i += direction + + return resolved, status + +def _estimate_per_tilt_angles(paths, tilt_of, angle_outlier_threshold, anchor_tilts=5, print_angles=False, output_dir=None): angles, confidences = {}, {} for path in paths: data, _ = readMrcFile(path) + if data is None: + continue angle, energy = estimate_curtain_angle(data[0]) median_energy = np.median(energy) confidences[path] = energy.max() / median_energy if median_energy > 0 else 0.0 angles[path] = angle logger.debug('({}) tilt {}° est. angle: {} (conf.: {})', path.name, tilt_of[path], angle, confidences[path]) - # A single spuriously sharp FFT peak can otherwise dominate its bucket's consensus and the overall weighted average + # A single spuriously sharp FFT peak can otherwise dominate its bucket's consensus + raw_confidences = dict(confidences) conf_values = np.array(list(confidences.values())) if len(conf_values): median_conf = np.median(conf_values) @@ -169,30 +257,98 @@ def _estimate_per_tilt_angles(paths, tilt_of, angle_outlier_threshold): bucket_confidences, ) bucket_consensus[bucket] = consensus - # High-tilt frames carry less signal (sample thickness grows ~1/cos(tilt)) so reduce weighting for overall consensus + # High-tilt frames carry less signal (sample thickness grows ~1/cos(tilt)) so reduce weighting within a series bucket_weight[bucket] = np.median(bucket_confidences) * np.cos(np.deg2rad(bucket)) logger.info('tilt {}°: consensus angle {}° (agreement: {}, n={})', bucket, f'{consensus:.1f}', f'{agreement:.3f}', len(bucket_paths)) - overall_consensus, overall_agreement = combine_angles( - list(bucket_consensus.values()), list(bucket_weight.values()) - ) - logger.info('overall consensus across {} tilt angle(s): {}° (agreement: {})', len(bucket_consensus), f'{overall_consensus:.1f}', f'{overall_agreement:.3f}') - - outlier_buckets = set() - for bucket, angle in bucket_consensus.items(): - deviation = min(abs(angle - overall_consensus), 180 - abs(angle - overall_consensus)) - if deviation > angle_outlier_threshold: - outlier_buckets.add(bucket) - good_buckets = sorted(set(bucket_consensus) - outlier_buckets) - resolved_consensus = dict(bucket_consensus) - if not good_buckets: - logger.warning('every tilt bucket deviates from the overall consensus - no reliable tilt to fall back on, using overall consensus ({}°) for all', f'{overall_consensus:.1f}') - resolved_consensus = {bucket: overall_consensus for bucket in bucket_consensus} - else: - for bucket in outlier_buckets: - own_angle = bucket_consensus[bucket] - deviation = min(abs(own_angle - overall_consensus), 180 - abs(own_angle - overall_consensus)) - neighbor = min(good_buckets, key=lambda g: abs(g - bucket)) - resolved_consensus[bucket] = bucket_consensus[neighbor] - logger.warning('tilt {}° consensus angle ({}°) deviates {}° from overall consensus ({}°) - using nearest reliable tilt {}°\'s angle ({}°) instead', bucket, f'{own_angle:.1f}', f'{deviation:.1f}', f'{overall_consensus:.1f}', neighbor, f'{bucket_consensus[neighbor]:.1f}') + series_list = _split_series(tilt_buckets) + resolved_consensus = {} + series_of_bucket = {} + status_of_bucket = {} + for series_id, series_buckets in enumerate(series_list): + series_resolved, series_status = _resolve_series(series_buckets, bucket_consensus, angle_outlier_threshold, anchor_tilts) + resolved_consensus.update(series_resolved) + status_of_bucket.update(series_status) + for b in series_buckets: + series_of_bucket[b] = series_id + logger.info('series {}: {} tilt bucket(s), tilts {}° to {}°', series_id, len(series_buckets), min(series_buckets), max(series_buckets)) + + if print_angles: + _report_angle_estimates(output_dir, paths, tilt_of, angles, raw_confidences, confidences, bucket_consensus, bucket_weight, series_of_bucket, status_of_bucket, resolved_consensus, series_list) + return {path: resolved_consensus[round(tilt_of[path])] for path in paths} + +def _report_angle_estimates( + output_dir, + paths, + tilt_of, + angles, + raw_confidences, + confidences, + bucket_consensus, + bucket_weight, + series_of_bucket, + status_of_bucket, + resolved_consensus, + series_list, +): + # create output directory + output_dir.mkdir(parents=True, exist_ok=True) + + # per-file estimates go to a CSV + csv_path = output_dir / 'per_file_angle_estimates.csv' + with open(csv_path, 'w', newline='') as f: + writer = csv.writer(f) + writer.writerow(['file', 'tilt_bucket', 'series', 'angle_deg', 'raw_confidence', 'clipped_confidence', 'bucket_status']) + for path in paths: + bucket = round(tilt_of[path]) + writer.writerow([ + path.name, + bucket, + series_of_bucket[bucket], + f'{angles[path]:.2f}', + f'{raw_confidences[path]:.3f}', + f'{confidences[path]:.3f}', + status_of_bucket[bucket], + ]) + logger.info('per-file angle estimates written to {}', csv_path) + + # per-tilt consensus is printed to stdout and csv + console = Console() + n_by_bucket = {} + for path in paths: + n_by_bucket[round(tilt_of[path])] = n_by_bucket.get(round(tilt_of[path]), 0) + 1 + for series_id, series_buckets in enumerate(series_list): + series_csv_path = output_dir / f'per_tilt_angle_estimates_{series_id}.csv' + buckets_sorted = sorted(series_buckets) + seed_buckets = sorted(b for b in buckets_sorted if status_of_bucket[b] == 'seed') + table = Table(title=f'Series {series_id}: tilts {buckets_sorted[0]}° to {buckets_sorted[-1]}° (anchor: {seed_buckets})') + table.add_column('tilt bucket', justify='right') + table.add_column('n', justify='right') + table.add_column('bucket consensus °', justify='right') + table.add_column('resolved °', justify='right') + table.add_column('weight', justify='right') + table.add_column('status', justify='center') + with open(series_csv_path, 'w', newline='') as f: + writer = csv.writer(f) + writer.writerow(['tilt bucket', 'n', 'bucket consensus °', 'resolved °', 'weight', 'status']) + for bucket in buckets_sorted: + status = status_of_bucket[bucket] + table.add_row( + str(bucket), + str(n_by_bucket[bucket]), + f'{bucket_consensus[bucket]:.1f}', + f'{resolved_consensus[bucket]:.1f}', + f'{bucket_weight[bucket]:.2f}', + f'[{_STATUS_COLORS[status]}]{status}[/{_STATUS_COLORS[status]}]', + ) + writer.writerow([ + str(bucket), + str(n_by_bucket[bucket]), + f'{bucket_consensus[bucket]:.1f}', + f'{resolved_consensus[bucket]:.1f}', + f'{bucket_weight[bucket]:.2f}', + status_of_bucket[bucket], + ]) + logger.info('per-tilt angle estimates for series {} written to {}', series_id, series_csv_path) + console.print(table) diff --git a/src/pylisc/io.py b/src/pylisc/io.py index 2b851dc..d72e4db 100644 --- a/src/pylisc/io.py +++ b/src/pylisc/io.py @@ -6,6 +6,8 @@ import mrcfile, numpy as np from pathlib import Path +# Import internal utilities +from pylisc.log import logger def nameOutputFile(input_path, mode): base_stem = f'{input_path.stem}_PyLisC_{mode}' @@ -20,12 +22,16 @@ def nameOutputFile(input_path, mode): return(output_mrc) def readMrcFile(path: Path): - with mrcfile.open(path, permissive=True) as mrc: - data = mrc.data.astype(np.float32) - voxel_size = mrc.voxel_size # in Ångstroms - if data.ndim == 2: - data = data[np.newaxis, ...] - return data, voxel_size + try: + with mrcfile.open(path, permissive=True) as mrc: + data = mrc.data.astype(np.float32) + voxel_size = mrc.voxel_size # in Ångstroms + if data.ndim == 2: + data = data[np.newaxis, ...] + return data, voxel_size + except ValueError as e: + logger.warning('({}) could not read MRC file, skipping', path) + return None, None def writeMrcFile(data, voxel_size, path: Path, force: bool = False): if path.exists() and not force: diff --git a/src/pylisc/stack.py b/src/pylisc/stack.py index 238fd68..2d81b1e 100644 --- a/src/pylisc/stack.py +++ b/src/pylisc/stack.py @@ -200,6 +200,8 @@ def _run_stack_batch( angles, confidences = [], [] for path in series_paths: data, _ = readMrcFile(path) + if data is None: + continue frame_index = _resolve_reference_frame(path, data, reference_frame) frame = data[frame_index] angle, energy = estimate_curtain_angle(frame) diff --git a/tests/conftest.py b/tests/conftest.py index e69de29..7e0c8af 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -0,0 +1,51 @@ +''' +PyLisC: test fixtures for generating synthetic MRC files +''' + +# Import external libraries +import numpy as np, pytest + +@pytest.fixture(scope="session") +def synthetic_frame(): + ''' + A synthetic tilt frame: large-scale background + fine texture + directional curtaining at a known angle + ''' + def _make(size=512, angle_deg=0.0, period_px=25, amplitude=60.0, noise_std=5.0, seed=0): + from scipy import ndimage as ndi + rng = np.random.default_rng(seed) + yy, xx = np.mgrid[0:size, 0:size] + theta = np.deg2rad(angle_deg) + stripe_coord = -xx * np.sin(theta) + yy * np.cos(theta) + curtains = amplitude * np.sin(stripe_coord / period_px * 2 * np.pi) + large_scale = ndi.gaussian_filter(rng.normal(0, 1, (size, size)), sigma=60) * 200 + fine = ndi.gaussian_filter(rng.normal(0, 1, (size, size)), sigma=2) * 20 + noise = rng.normal(0, noise_std, (size, size)) + return (1000 + large_scale + fine + curtains + noise).astype(np.float32) + return _make + +@pytest.fixture(scope="session") +def synthetic_tilt_series(synthetic_frame): + def _make(n_tilts=5, angle_deg=0.0, **frame_kwargs): + return np.stack([ + synthetic_frame(angle_deg=angle_deg, seed=i, **frame_kwargs) + for i in range(n_tilts) + ]) + return _make + +@pytest.fixture(scope="session") +def write_synthetic_mrc(): + def _make(path, stack, pixel_size_nm=3.4): + import mrcfile + with mrcfile.new(path, overwrite=True) as mrc: + mrc.set_data(stack.astype(np.float32)) + mrc.voxel_size = pixel_size_nm * 10 # nm -> Angstrom + return _make + +@pytest.fixture(scope="session") +def write_synthetic_frame(synthetic_frame, write_synthetic_mrc): + ''' + Write a single synthetic 2D frame (as used in frames mode) + ''' + def _make(path, angle_deg=0.0, pixel_size_nm=3.4, seed=0, **frame_kwargs): + write_synthetic_mrc(path, synthetic_frame(angle_deg=angle_deg, seed=seed, **frame_kwargs), pixel_size_nm=pixel_size_nm) + return _make diff --git a/tests/fixtures.py b/tests/fixtures.py deleted file mode 100644 index a62f155..0000000 --- a/tests/fixtures.py +++ /dev/null @@ -1,43 +0,0 @@ -''' -PyLisC: test fixtures for generating synthetic MRC files -''' - -# Import external libraries -import numpy as np - - -def synthetic_frame(size=512, angle_deg=0.0, period_px=25, amplitude=60.0, noise_std=5.0, seed=0): - ''' - A synthetic tilt frame: large-scale background + fine texture + directional curtaining at a known angle - ''' - from scipy import ndimage as ndi - rng = np.random.default_rng(seed) - yy, xx = np.mgrid[0:size, 0:size] - theta = np.deg2rad(angle_deg) - stripe_coord = -xx * np.sin(theta) + yy * np.cos(theta) - curtains = amplitude * np.sin(stripe_coord / period_px * 2 * np.pi) - large_scale = ndi.gaussian_filter(rng.normal(0, 1, (size, size)), sigma=60) * 200 - fine = ndi.gaussian_filter(rng.normal(0, 1, (size, size)), sigma=2) * 20 - noise = rng.normal(0, noise_std, (size, size)) - return (1000 + large_scale + fine + curtains + noise).astype(np.float32) - - -def synthetic_tilt_series(n_tilts=5, angle_deg=0.0, **frame_kwargs): - return np.stack([ - synthetic_frame(angle_deg=angle_deg, seed=i, **frame_kwargs) - for i in range(n_tilts) - ]) - - -def write_synthetic_mrc(path, stack, pixel_size_nm=3.4): - import mrcfile - with mrcfile.new(path, overwrite=True) as mrc: - mrc.set_data(stack.astype(np.float32)) - mrc.voxel_size = pixel_size_nm * 10 # nm -> Angstrom - - -def write_synthetic_frame(path, angle_deg=0.0, pixel_size_nm=3.4, seed=0, **frame_kwargs): - ''' - Write a single synthetic 2D frame (as used in frames mode) - ''' - write_synthetic_mrc(path, synthetic_frame(angle_deg=angle_deg, seed=seed, **frame_kwargs), pixel_size_nm=pixel_size_nm) \ No newline at end of file diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py index ee406c0..24b1884 100644 --- a/tests/integration/test_cli.py +++ b/tests/integration/test_cli.py @@ -9,8 +9,7 @@ runner = CliRunner() class TestCliSingle: - def test_single_file_run(self, tmp_path): - from tests.fixtures import synthetic_tilt_series, write_synthetic_mrc + def test_single_file_run(self, tmp_path, synthetic_tilt_series, write_synthetic_mrc): stack = synthetic_tilt_series(n_tilts=3, angle_deg=20) input_path = tmp_path / 'series.mrc' write_synthetic_mrc(input_path, stack) @@ -20,8 +19,7 @@ def test_single_file_run(self, tmp_path): 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 + def test_rerun_without_force_fails_then_succeeds_with_force(self, tmp_path, 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' @@ -37,8 +35,7 @@ def test_rerun_without_force_fails_then_succeeds_with_force(self, tmp_path): 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 + def test_dry_run_writes_nothing(self, tmp_path, 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)) @@ -46,8 +43,7 @@ def test_dry_run_writes_nothing(self, tmp_path): 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 + def test_reference_frame_at_series_length_is_rejected_cleanly(self, tmp_path, 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)) @@ -58,8 +54,7 @@ def test_reference_frame_at_series_length_is_rejected_cleanly(self, tmp_path): assert 'IndexError' not in result.output class TestCliBatch: - def test_batch_run_with_outlier_warning(self, tmp_path): - from tests.fixtures import synthetic_tilt_series, write_synthetic_mrc + def test_batch_run_with_outlier_warning(self, tmp_path, synthetic_tilt_series, write_synthetic_mrc): input_dir = tmp_path / 'raw' input_dir.mkdir() output_dir = tmp_path / 'cleared' @@ -74,8 +69,7 @@ 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() - 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 + def test_batch_reference_frame_out_of_range_does_not_crash_whole_batch(self, tmp_path, synthetic_tilt_series, write_synthetic_mrc): input_dir = tmp_path / 'raw' input_dir.mkdir() output_dir = tmp_path / 'cleared' @@ -90,8 +84,7 @@ def test_batch_reference_frame_out_of_range_does_not_crash_whole_batch(self, tmp 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 + def test_preview_exits_without_full_run(self, tmp_path, synthetic_tilt_series, write_synthetic_mrc): stack = synthetic_tilt_series(n_tilts=3) input_path = tmp_path / 'series.mrc' write_synthetic_mrc(input_path, stack) @@ -101,8 +94,7 @@ 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 + def test_zero_angular_width_is_rejected(self, tmp_path, 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)) @@ -112,24 +104,28 @@ def test_zero_angular_width_is_rejected(self, tmp_path): 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 + def test_frames_run_groups_by_tilt_and_warns_on_outlier(self, tmp_path, write_synthetic_frame): input_dir = tmp_path / 'raw' input_dir.mkdir() output_dir = tmp_path / 'cleared' template = '{}_{position}_{}_{tilt}_{}_{}_{}_{}_{}.mrc' - # two positions at matching nominal tilts, same striping angle + # three tilts, two positions each, all striped at the same angle write_synthetic_frame(input_dir / 'Position_1_1_-10.00_20240101_1_Fractions_motion_corrected.mrc', angle_deg=20, seed=0) write_synthetic_frame(input_dir / 'Position_2_1_-9.98_20240101_1_Fractions_motion_corrected.mrc', angle_deg=20, seed=1) - # a deliberate outlier at another tilt - write_synthetic_frame(input_dir / 'Position_1_2_0.00_20240101_1_Fractions_motion_corrected.mrc', angle_deg=65, seed=2) + write_synthetic_frame(input_dir / 'Position_1_2_0.00_20240101_1_Fractions_motion_corrected.mrc', angle_deg=20, seed=3) + write_synthetic_frame(input_dir / 'Position_2_2_0.02_20240101_1_Fractions_motion_corrected.mrc', angle_deg=20, seed=4) + write_synthetic_frame(input_dir / 'Position_1_3_10.00_20240101_1_Fractions_motion_corrected.mrc', angle_deg=20, seed=0) + write_synthetic_frame(input_dir / 'Position_2_3_9.98_20240101_1_Fractions_motion_corrected.mrc', angle_deg=20, seed=1) + # a deliberate outlier at another tilt, past the anchor window + write_synthetic_frame(input_dir / 'Position_1_4_20.00_20240101_1_Fractions_motion_corrected.mrc', angle_deg=65, seed=2) result = runner.invoke(pylisc, [ 'frames', str(input_dir), '--output-dir', str(output_dir), '--filename-template', template, '--mode', 'angular', + '--anchor-tilts', '2', ]) assert result.exit_code == 0 assert 'WARNING' in result.output @@ -137,8 +133,7 @@ def test_frames_run_groups_by_tilt_and_warns_on_outlier(self, tmp_path): assert out_file.exists() assert (output_dir / f'{out_file.stem}.log').exists() - def test_frames_requires_pixel_size_for_filter(self, tmp_path): - from tests.fixtures import write_synthetic_frame + def test_frames_requires_pixel_size_for_filter(self, tmp_path, write_synthetic_frame): input_dir = tmp_path / 'raw' input_dir.mkdir() write_synthetic_frame(input_dir / 'Position_1_1_-10.00_20240101_1_Fractions_motion_corrected.mrc') @@ -150,4 +145,71 @@ def test_frames_requires_pixel_size_for_filter(self, tmp_path): '--apply-filter', ]) assert result.exit_code != 0 - assert '--pixel-size' in result.output \ No newline at end of file + assert '--pixel-size' in result.output + +class TestCliVersionAndWarnings: + def test_version_flag_prints_and_exits(self): + result = runner.invoke(pylisc, ['--version']) + assert result.exit_code == 0 + assert 'PyLisC version' in result.output + + def test_linear_mode_warns_deprecated(self, tmp_path, synthetic_tilt_series, write_synthetic_mrc): + input_path = tmp_path / 'series.mrc' + write_synthetic_mrc(input_path, synthetic_tilt_series(n_tilts=2, angle_deg=20)) + + result = runner.invoke(pylisc, ['stack', str(input_path), '--mode', 'linear']) + assert result.exit_code == 0 + assert 'deprecated' in result.output + + def test_single_file_mode_warns_on_ignored_output_dir(self, tmp_path, synthetic_tilt_series, write_synthetic_mrc): + input_path = tmp_path / 'series.mrc' + write_synthetic_mrc(input_path, synthetic_tilt_series(n_tilts=2, angle_deg=20)) + + result = runner.invoke(pylisc, [ + 'stack', str(input_path), '--mode', 'angular', + '--output-dir', str(tmp_path / 'ignored'), + ]) + assert result.exit_code == 0 + assert 'ignoring option: --output-dir' in result.output + + def test_batch_mode_warns_on_ignored_output_arg_and_preview(self, tmp_path, synthetic_tilt_series, write_synthetic_mrc): + input_dir = tmp_path / 'raw' + input_dir.mkdir() + write_synthetic_mrc(input_dir / 'a.mrc', synthetic_tilt_series(n_tilts=2, angle_deg=20)) + + result = runner.invoke(pylisc, [ + 'stack', str(input_dir), str(tmp_path / 'ignored_out.mrc'), + '--mode', 'angular', '--output-dir', str(tmp_path / 'cleared'), + '--preview-strengths', '3,8', + ]) + assert result.exit_code == 0 + assert 'ignoring argument' in result.output + assert 'ignoring option: --preview-strengths' in result.output + +class TestCliFramesOptions: + def test_zero_angular_width_is_rejected(self, tmp_path, write_synthetic_frame): + input_dir = tmp_path / 'raw' + input_dir.mkdir() + write_synthetic_frame(input_dir / 'a_0.00.mrc', angle_deg=20) + + result = runner.invoke(pylisc, [ + 'frames', str(input_dir), + '--output-dir', str(tmp_path / 'cleared'), + '--filename-template', '{}_{tilt}.mrc', + '--angular-width', '0', + ]) + assert result.exit_code != 0 + + def test_linear_mode_warns_deprecated(self, tmp_path, write_synthetic_frame): + input_dir = tmp_path / 'raw' + input_dir.mkdir() + write_synthetic_frame(input_dir / 'a_0.00.mrc', angle_deg=20) + + result = runner.invoke(pylisc, [ + 'frames', str(input_dir), + '--output-dir', str(tmp_path / 'cleared'), + '--filename-template', '{}_{tilt}.mrc', + '--mode', 'linear', + ]) + assert result.exit_code == 0 + assert 'deprecated' in result.output \ No newline at end of file diff --git a/tests/unit/test_angle.py b/tests/unit/test_angle.py index 0e8faf2..59913b4 100644 --- a/tests/unit/test_angle.py +++ b/tests/unit/test_angle.py @@ -7,13 +7,12 @@ # Import intneral functions from pylisc.estimate_angle import combine_angles, estimate_curtain_angle -from tests.fixtures import synthetic_frame # Test angle estimation class TestEstimateCurtainAngle: @pytest.mark.parametrize('true_angle', [0, 15, 30, -25, 45, 60, 90, -70]) @pytest.mark.parametrize('period', [10, 40, 100]) - def test_recovers_known_angle(self, true_angle, period): + def test_recovers_known_angle(self, true_angle, period, synthetic_frame): frame = synthetic_frame(size=2048, angle_deg=true_angle, period_px=period, seed=2) estimated, _ = estimate_curtain_angle(frame) error = min(abs(estimated - true_angle), abs(abs(estimated - true_angle) - 180)) @@ -27,7 +26,7 @@ def test_low_confidence_on_pure_noise(self): assert energy.max() / np.median(energy) < 5 # no dominant direction - def test_high_confidence_on_real_curtaining(self): + def test_high_confidence_on_real_curtaining(self, synthetic_frame): frame = synthetic_frame(size=2048, angle_deg=20, period_px=40, seed=3) _, energy = estimate_curtain_angle(frame) assert energy.max() / np.median(energy) > 100 # clear peak @@ -39,13 +38,11 @@ def test_identical_angles_give_perfect_agreement(self): assert angle == pytest.approx(30, abs=0.01) assert agreement == pytest.approx(1.0, abs=0.001) - def test_wraparound_averages_correctly(self): angle, agreement = combine_angles([89, -89], [5, 5]) assert abs(angle) > 85 # near +-90, not near 0 assert agreement > 0.99 - def test_low_confidence_outlier_is_downweighted(self): angle, _ = combine_angles([30, 30, -10], [5, 5, 0.5]) assert abs(angle - 30) < abs(angle - (-10)) \ No newline at end of file diff --git a/tests/unit/test_blur.py b/tests/unit/test_blur.py new file mode 100644 index 0000000..d848e1a --- /dev/null +++ b/tests/unit/test_blur.py @@ -0,0 +1,34 @@ +''' +PyLisC: unit tests for FFT-based blur/high-pass filtering +''' + +# Import external libraries +import numpy as np + +# Import internal functions +from pylisc.blur import bandpass_highpass, gaussian_blur_fft + +class TestBlur: + def test_gaussian_blur_attenuates_fine_texture(self, synthetic_frame): + frame = synthetic_frame(size=128, amplitude=0.0, noise_std=0.0) + blurred = gaussian_blur_fft(frame, sigma=10.0) + assert blurred.std() < frame.std() + assert blurred.shape == frame.shape + + def test_gaussian_blur_zero_sigma_is_near_identity(self, synthetic_frame): + frame = synthetic_frame(size=64, amplitude=0.0, noise_std=0.0) + blurred = gaussian_blur_fft(frame, sigma=0.0) + np.testing.assert_allclose(blurred, frame, rtol=1e-4, atol=1e-2) + + def test_bandpass_highpass_removes_large_scale_background(self): + rng = np.random.default_rng(1) + # large-scale ramp the high-pass should strip out + yy, _ = np.mgrid[0:128, 0:128] + background = (yy * 5.0).astype(np.float32) + fine = rng.normal(0, 1, (128, 128)).astype(np.float32) + frame = background + fine + + result = bandpass_highpass(frame, sigma_large=20.0) + + # mean/large-scale trend should be far smaller relative to the fine texture + assert abs(result.mean()) < abs(frame.mean()) diff --git a/tests/unit/test_destripe.py b/tests/unit/test_destripe.py index 8d15869..b88fa45 100644 --- a/tests/unit/test_destripe.py +++ b/tests/unit/test_destripe.py @@ -4,13 +4,22 @@ # Import internal functions from pylisc.destripe import directional_destripe_angular, directional_destripe_linear -from tests.fixtures import synthetic_frame class TestDestripe: def retained_fraction(self, clean_component, angle_deg, destripe_fn, **kwargs): out = destripe_fn(clean_component, angle_deg=angle_deg, **kwargs) return out.std() / clean_component.std() + def test_linear_mode_dc_protect_preserves_low_frequencies(self): + from scipy import ndimage as ndi + import numpy as np + rng = np.random.default_rng(5) + large_scale = (ndi.gaussian_filter(rng.normal(0, 1, (1024, 1024)), sigma=60) * 200).astype('float32') + + kept_without_protect = self.retained_fraction(large_scale, -65, directional_destripe_linear, notch_frac=0.02, dc_protect_frac=0.0) + kept_with_protect = self.retained_fraction(large_scale, -65, directional_destripe_linear, notch_frac=0.02, dc_protect_frac=0.05) + assert kept_with_protect > kept_without_protect + def test_angular_mode_decouples_from_curtain_period(self): # Passing large-scale/fine components through ALONE isolates the filter's effect on each, since it's linear from scipy import ndimage as ndi @@ -32,10 +41,9 @@ def test_linear_mode_isotropic_loss_without_dc_protect(self): kept = self.retained_fraction(large_scale, -65, directional_destripe_linear, notch_frac=0.02, dc_protect_frac=0.0) assert kept < 0.2 - def test_zero_angular_width_does_not_produce_nan(self): + def test_zero_angular_width_does_not_produce_nan(self, synthetic_frame): 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) diff --git a/tests/unit/test_frames.py b/tests/unit/test_frames.py index 1a76f41..d2b80eb 100644 --- a/tests/unit/test_frames.py +++ b/tests/unit/test_frames.py @@ -1,17 +1,17 @@ ''' -PyLisC: unit tests for per-tilt curtain angle consensus and outlier fallback +PyLisC: unit tests for per-tilt curtain angle consensus and outlier fallback, and frames processing ''' # Import external libraries -import pytest +import pytest, typer # Import internal functions -from pylisc.frames import _estimate_per_tilt_angles +from pylisc.frames import _estimate_per_tilt_angles, _process_one, _split_by_tilt_gap, run_frames +from pylisc.io import readMrcFile from pylisc.log import logger -from tests.fixtures import write_synthetic_frame class TestEstimatePerTiltAngles: - def test_overall_consensus_favors_low_tilt_over_high_tilt(self, tmp_path): + def test_overall_consensus_favors_low_tilt_over_high_tilt(self, tmp_path, write_synthetic_frame): paths, tilt_of = [], {} for i, tilt in enumerate([0, 1, 2]): path = tmp_path / f'low_{i}.mrc' @@ -28,46 +28,32 @@ def test_overall_consensus_favors_low_tilt_over_high_tilt(self, tmp_path): assert low_tilt_consensus == pytest.approx(20, abs=1.0) - def test_outlier_tilt_falls_back_to_nearest_reliable_neighbor(self, tmp_path): + def test_walk_rejects_bucket_that_deviates_from_trusted_neighbor(self, tmp_path, write_synthetic_frame): paths, tilt_of = [], {} - # a reliable cluster around 0deg tilt, all striped at 50deg + # a reliable run around the median tilt, all striped at 50deg for i, tilt in enumerate([-2, -1, 0, 1, 2]): path = tmp_path / f'good_{i}.mrc' write_synthetic_frame(path, angle_deg=50, seed=i) paths.append(path) tilt_of[path] = tilt - # a lone bad estimate at 3deg tilt, right next to the good cluster + # a lone bad estimate just past the seed window bad_path = tmp_path / 'bad.mrc' write_synthetic_frame(bad_path, angle_deg=-40, seed=42) paths.append(bad_path) tilt_of[bad_path] = 3 - resolved = _estimate_per_tilt_angles(paths, tilt_of, angle_outlier_threshold=5.0) - # the bad tilt should destripe at its nearest good neighbor's angle (~50deg), not its own different estimate (~-40deg) - assert resolved[bad_path] == pytest.approx(50, abs=2.0) - - def test_all_buckets_outlier_falls_back_to_overall_consensus(self, tmp_path): - paths, tilt_of = [], {} - # two tilts that wildly disagree with each other - for i, (tilt, angle) in enumerate([(-10, 10), (10, -70)]): - path = tmp_path / f'p_{i}.mrc' - write_synthetic_frame(path, angle_deg=angle, seed=i) - paths.append(path) - tilt_of[path] = tilt - - # loguru's global default sink is enqueue=True messages = [] sink_id = logger.add(messages.append, level='WARNING') try: - resolved = _estimate_per_tilt_angles(paths, tilt_of, angle_outlier_threshold=1.0) + resolved = _estimate_per_tilt_angles(paths, tilt_of, angle_outlier_threshold=5.0, anchor_tilts=5) finally: logger.remove(sink_id) - assert resolved[paths[0]] == resolved[paths[1]] - assert any('no reliable tilt' in str(m) for m in messages) - + # falls back to the trusted (seed) angle, not its own wildly different estimate + assert resolved[bad_path] == pytest.approx(50, abs=2.0) + assert any('deviates' in str(m) and 'nearest resolved angle' in str(m) for m in messages) - def test_confidence_spike_does_not_skew_overall_consensus(self, tmp_path): + def test_confidence_spike_does_not_skew_overall_consensus(self, tmp_path, write_synthetic_frame): paths, tilt_of = [], {} # a consistent low-tilt cluster, all striped at 20deg with ordinary confidence for i, tilt in enumerate([-10, 0, 10]): @@ -81,6 +67,153 @@ def test_confidence_spike_does_not_skew_overall_consensus(self, tmp_path): paths.append(spike_path) tilt_of[spike_path] = 50 - resolved = _estimate_per_tilt_angles(paths, tilt_of, angle_outlier_threshold=15.0) + resolved = _estimate_per_tilt_angles(paths, tilt_of, angle_outlier_threshold=5.0, anchor_tilts=5, output_dir=tmp_path) # the low-tilt cluster should still win the overall consensus, not be outvoted by the single spiky frame assert resolved[tmp_path / 'low_0.mrc'] == pytest.approx(20, abs=1.0) + + def test_print_angles_does_not_change_result(self, tmp_path, write_synthetic_frame): + paths, tilt_of = [], {} + # a reliable cluster around 0deg tilt, all striped at 50deg + for i, tilt in enumerate([-2, -1, 0, 1, 2]): + path = tmp_path / f'good_{i}.mrc' + write_synthetic_frame(path, angle_deg=50, seed=i) + paths.append(path) + tilt_of[path] = tilt + angle_outlier_threshold = 5.0 + without_print = _estimate_per_tilt_angles(paths, tilt_of, angle_outlier_threshold, print_angles=False, anchor_tilts=5, output_dir=tmp_path) + with_print = _estimate_per_tilt_angles(paths, tilt_of, angle_outlier_threshold, print_angles=True, anchor_tilts=5, output_dir=tmp_path) + assert with_print == without_print + + def test_series_with_different_pretilt_are_resolved_independently(self, tmp_path, write_synthetic_frame): + paths, tilt_of = [], {} + # main series: 3 frames per tilt, regular 3deg step, striped at 55deg + for tilt in range(-6, 7, 3): + for i in range(3): + path = tmp_path / f'main_{tilt}_{i}.mrc' + write_synthetic_frame(path, angle_deg=55, seed=(tilt + 100) * 10 + i) + paths.append(path) + tilt_of[path] = tilt + # sparse series: different pretilt (offset by 1deg), 1 frame per tilt, + # same regular 3deg step, striped at a totally different angle + for tilt in range(-5, 8, 3): + path = tmp_path / f'sparse_{tilt}.mrc' + write_synthetic_frame(path, angle_deg=-20, seed=1000 + tilt) + paths.append(path) + tilt_of[path] = tilt + + resolved = _estimate_per_tilt_angles(paths, tilt_of, angle_outlier_threshold=5.0, anchor_tilts=5) + + assert resolved[tmp_path / 'main_0_0.mrc'] == pytest.approx(55, abs=1.0) + assert resolved[tmp_path / 'sparse_1.mrc'] == pytest.approx(-20, abs=1.0) + + def test_walk_tracks_gradual_angle_drift_with_tilt(self, tmp_path, write_synthetic_frame): + paths, tilt_of = [], {} + tilts = list(range(-9, 10, 3)) + for i, tilt in enumerate(tilts): + path = tmp_path / f'drift_{tilt}.mrc' + # angle drifts smoothly from 50deg to 62deg as tilt increases + angle = 50 + (tilt - tilts[0]) * (12 / (tilts[-1] - tilts[0])) + write_synthetic_frame(path, angle_deg=angle, seed=i) + paths.append(path) + tilt_of[path] = tilt + + resolved = _estimate_per_tilt_angles(paths, tilt_of, angle_outlier_threshold=5.0, anchor_tilts=3) + + assert resolved[tmp_path / f'drift_{tilts[0]}.mrc'] == pytest.approx(50, abs=1.5) + assert resolved[tmp_path / f'drift_{tilts[-1]}.mrc'] == pytest.approx(62, abs=1.5) + +class TestProcessOne: + def test_writes_cleared_frame(self, tmp_path, write_synthetic_frame): + in_path = tmp_path / 'frame.mrc' + write_synthetic_frame(in_path, angle_deg=20) + out_path = tmp_path / 'out.mrc' + + _process_one( + in_path, out_path, curtain_angle=20.0, mode='angular', + pixel_size=None, apply_filter=False, filter_threshold=5000.0, + angular_width=8.0, notch_frac=0.02, dc_protect_frac=0.01, force=False, + ) + + assert out_path.exists() + data, _ = readMrcFile(out_path) + assert data.shape[1:] == (512, 512) + + def test_reraises_on_failure(self, tmp_path): + bad_path = tmp_path / 'not_real.mrc' + bad_path.write_bytes(b'not a real mrc file') + out_path = tmp_path / 'out.mrc' + + with pytest.raises(Exception): + _process_one( + bad_path, out_path, curtain_angle=0.0, mode='angular', + pixel_size=None, apply_filter=False, filter_threshold=5000.0, + angular_width=8.0, notch_frac=0.02, dc_protect_frac=0.01, force=False, + ) + +class TestSplitByTiltGap: + def test_short_list_returned_unsplit(self): + assert _split_by_tilt_gap([1, 2]) == [[1, 2]] + +class TestRunFramesEdgeCases: + def test_no_frame_files_raises(self, tmp_path): + input_dir = tmp_path / 'raw' + input_dir.mkdir() + + with pytest.raises(typer.BadParameter, match='No frame files found'): + run_frames( + input_dir=input_dir, output_dir=tmp_path / 'out', + filename_template='{}_{tilt}.mrc', filename_delimiters='_', + mode='angular', apply_filter=False, filter_threshold=5000.0, + pixel_size=None, curtain_angle=None, angular_width=8.0, + notch_frac=0.02, dc_protect_frac=0.01, angle_outlier_threshold=5.0, + anchor_tilts=5, force=False, dry_run=False, workers=1, print_angles=False, + ) + + def test_explicit_curtain_angle_skips_estimation(self, tmp_path, write_synthetic_frame): + input_dir = tmp_path / 'raw' + input_dir.mkdir() + output_dir = tmp_path / 'out' + write_synthetic_frame(input_dir / 'a_0.00.mrc', angle_deg=20) + + run_frames( + input_dir=input_dir, output_dir=output_dir, + filename_template='{}_{tilt}.mrc', filename_delimiters='_', + mode='angular', apply_filter=False, filter_threshold=5000.0, + pixel_size=None, curtain_angle=33.0, angular_width=8.0, + notch_frac=0.02, dc_protect_frac=0.01, angle_outlier_threshold=5.0, + anchor_tilts=5, force=False, dry_run=False, workers=1, print_angles=False, + ) + assert (output_dir / 'a_0.00_PyLisC_angular.mrc').exists() + + def test_dry_run_writes_nothing(self, tmp_path, write_synthetic_frame): + input_dir = tmp_path / 'raw' + input_dir.mkdir() + output_dir = tmp_path / 'out' + write_synthetic_frame(input_dir / 'a_0.00.mrc', angle_deg=20) + + run_frames( + input_dir=input_dir, output_dir=output_dir, + filename_template='{}_{tilt}.mrc', filename_delimiters='_', + mode='angular', apply_filter=False, filter_threshold=5000.0, + pixel_size=None, curtain_angle=20.0, angular_width=8.0, + notch_frac=0.02, dc_protect_frac=0.01, angle_outlier_threshold=5.0, + anchor_tilts=5, force=False, dry_run=True, workers=1, print_angles=False, + ) + assert not (output_dir / 'a_0.00_PyLisC_angular.mrc').exists() + + def test_batch_one_bad_file_does_not_abort_others(self, tmp_path, write_synthetic_frame): + input_dir = tmp_path / 'raw' + input_dir.mkdir() + output_dir = tmp_path / 'out' + write_synthetic_frame(input_dir / 'good_0.00.mrc', angle_deg=20) + (input_dir / 'bad_1.00.mrc').write_bytes(b'not a real mrc file') + + run_frames( + input_dir=input_dir, output_dir=output_dir, + filename_template='{}_{tilt}.mrc', filename_delimiters='_', + mode='angular', apply_filter=False, filter_threshold=5000.0, + pixel_size=None, curtain_angle=20.0, angular_width=8.0, + notch_frac=0.02, dc_protect_frac=0.01, angle_outlier_threshold=5.0, + anchor_tilts=5, force=False, dry_run=False, workers=2, print_angles=False, + ) + assert (output_dir / 'good_0.00_PyLisC_angular.mrc').exists() \ No newline at end of file diff --git a/tests/unit/test_io.py b/tests/unit/test_io.py index 3377e09..239de14 100644 --- a/tests/unit/test_io.py +++ b/tests/unit/test_io.py @@ -6,7 +6,7 @@ import numpy as np, pytest # -- Import internal functions -from pylisc.io import readMrcFile, writeMrcFile +from pylisc.io import nameOutputFile, readMrcFile, writeMrcFile from pylisc.stack import _default_output_path class TestIo: @@ -20,8 +20,7 @@ def test_default_output_path_appends_numeric_suffix_on_collision(self, tmp_path) 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 + def test_write_then_read_roundtrip(self, tmp_path, synthetic_frame): path = tmp_path / 'out.mrc' frame = synthetic_frame(size=64)[np.newaxis, ...] @@ -46,3 +45,21 @@ def test_write_overwrites_existing_file_with_force(self, tmp_path): writeMrcFile(np.zeros((1, 4, 4), dtype=np.float32), 10.0, path, force=True) data, _ = readMrcFile(path) assert data.shape == (1, 4, 4) + +class TestIoNameOutputFile: + def test_no_collision_returns_base_name(self, tmp_path): + input_path = tmp_path / 'series.mrc' + input_path.touch() + + out_path = nameOutputFile(input_path, mode='angular') + + assert out_path == tmp_path / 'series_PyLisC_angular.mrc' + + def test_collision_appends_numeric_suffix(self, tmp_path): + input_path = tmp_path / 'series.mrc' + input_path.touch() + (tmp_path / 'series_PyLisC_angular.mrc').touch() + + out_path = nameOutputFile(input_path, mode='angular') + + assert out_path == tmp_path / 'series_PyLisC_angular_1.mrc' diff --git a/tests/unit/test_lisc.py b/tests/unit/test_lisc.py new file mode 100644 index 0000000..930bc16 --- /dev/null +++ b/tests/unit/test_lisc.py @@ -0,0 +1,32 @@ +''' +PyLisC: unit tests for the single-frame LisC pipeline +''' + +# Import external libraries +import pytest + +# Import internal functions +from pylisc.lisc import lisc_clear_frame + +class TestLiscClearFrame: + def test_apply_filter_without_pixel_size_raises(self, synthetic_frame): + frame = synthetic_frame(size=64) + with pytest.raises(ValueError, match='pixel_size_nm is required'): + lisc_clear_frame(frame, decurtaining_mode='angular', apply_filter=True) + + def test_apply_filter_runs_highpass_before_destriping(self, synthetic_frame): + frame = synthetic_frame(size=128, angle_deg=20) + cleared = lisc_clear_frame( + frame, + decurtaining_mode='angular', + pixel_size_nm=0.34, + apply_filter=True, + filter_threshold_nm=5000.0, + ) + assert cleared.shape == frame.shape + assert cleared.dtype.name == 'float32' + + def test_linear_mode_dispatches_to_linear_destripe(self, synthetic_frame): + frame = synthetic_frame(size=64, angle_deg=0) + cleared = lisc_clear_frame(frame, decurtaining_mode='linear', curtain_angle=0.0) + assert cleared.shape == frame.shape diff --git a/tests/unit/test_preview.py b/tests/unit/test_preview.py new file mode 100644 index 0000000..11c702a --- /dev/null +++ b/tests/unit/test_preview.py @@ -0,0 +1,25 @@ +''' +PyLisC: unit tests for strength preview generation +''' + +# Import internal functions +from pylisc.preview import generate_strength_preview + +class TestGenerateStrengthPreview: + def test_single_value_angular(self, tmp_path, synthetic_frame): + frame = synthetic_frame(size=64, angle_deg=20) + out_path = generate_strength_preview( + frame, mode='angular', pixel_size_nm=0.34, values=[8.0], + curtain_angle=20.0, apply_filter=False, filter_threshold_nm=5000.0, + dc_protect_frac=0.01, notch_frac=0.02, output_dir=tmp_path, + ) + assert out_path.exists() + + def test_linear_mode_multiple_values(self, tmp_path, synthetic_frame): + frame = synthetic_frame(size=64, angle_deg=20) + out_path = generate_strength_preview( + frame, mode='linear', pixel_size_nm=0.34, values=[0.01, 0.05], + curtain_angle=20.0, apply_filter=False, filter_threshold_nm=5000.0, + dc_protect_frac=0.01, notch_frac=0.02, output_dir=tmp_path, + ) + assert out_path.exists() diff --git a/tests/unit/test_stack.py b/tests/unit/test_stack.py new file mode 100644 index 0000000..30b599a --- /dev/null +++ b/tests/unit/test_stack.py @@ -0,0 +1,83 @@ +''' +PyLisC: unit tests for stack-mode processing +''' + +# Import external libraries +import pytest + +# Import internal functions +from pylisc.stack import _process_series, run_stack + +class TestProcessSeries: + def test_explicit_pixel_size_overrides_header(self, tmp_path, synthetic_tilt_series, write_synthetic_mrc): + input_path = tmp_path / 'series.mrc' + write_synthetic_mrc(input_path, synthetic_tilt_series(n_tilts=2, angle_deg=20)) + out_path = tmp_path / 'out.mrc' + + angle = _process_series( + input_path, out_path, mode='angular', apply_filter=False, + filter_threshold=5000.0, pixel_size=0.5, curtain_angle=20.0, + reference_frame=0, angular_width=8.0, notch_frac=0.02, + dc_protect_frac=0.01, force=False, dry_run=False, + ) + assert angle == pytest.approx(20.0) + assert out_path.exists() + + def test_invalid_pixel_size_raises(self, tmp_path, synthetic_tilt_series, write_synthetic_mrc): + input_path = tmp_path / 'series.mrc' + write_synthetic_mrc(input_path, synthetic_tilt_series(n_tilts=2, angle_deg=20)) + out_path = tmp_path / 'out.mrc' + + with pytest.raises(ValueError, match='Pixel size cannot be'): + _process_series( + input_path, out_path, mode='angular', apply_filter=False, + filter_threshold=5000.0, pixel_size=0.0, curtain_angle=20.0, + reference_frame=0, angular_width=8.0, notch_frac=0.02, + dc_protect_frac=0.01, force=False, dry_run=False, + ) + + def test_explicit_curtain_angle_skips_estimation(self, tmp_path, synthetic_tilt_series, write_synthetic_mrc): + input_path = tmp_path / 'series.mrc' + write_synthetic_mrc(input_path, synthetic_tilt_series(n_tilts=2, angle_deg=20)) + out_path = tmp_path / 'out.mrc' + + angle = _process_series( + input_path, out_path, mode='angular', apply_filter=False, + filter_threshold=5000.0, pixel_size=0.34, curtain_angle=33.0, + reference_frame=0, angular_width=8.0, notch_frac=0.02, + dc_protect_frac=0.01, force=False, dry_run=False, + ) + assert angle == 33.0 + +class TestRunStackBatch: + def test_no_series_found_raises(self, tmp_path): + import typer + input_dir = tmp_path / 'raw' + input_dir.mkdir() + output_dir = tmp_path / 'cleared' + + with pytest.raises(typer.BadParameter, match='No tilt series found'): + run_stack( + input_path=input_dir, output_mrc=None, output_dir=output_dir, + mode='angular', apply_filter=False, filter_threshold=5000.0, + pixel_size=None, curtain_angle=None, reference_frame=None, + angular_width=8.0, notch_frac=0.02, dc_protect_frac=0.01, + angle_outlier_threshold=5.0, force=False, dry_run=False, workers=0, + ) + + def test_batch_one_bad_file_does_not_abort_others(self, tmp_path, synthetic_tilt_series, write_synthetic_mrc): + # mirrors tests/integration/test_cli.py::TestCliBatch, but checks a corrupt file fails in-process while its sibling still completes + input_dir = tmp_path / 'raw' + input_dir.mkdir() + output_dir = tmp_path / 'cleared' + write_synthetic_mrc(input_dir / 'good.mrc', synthetic_tilt_series(n_tilts=3, angle_deg=20)) + (input_dir / 'bad.mrc').write_bytes(b'not a real mrc file') + + run_stack( + input_path=input_dir, output_mrc=None, output_dir=output_dir, + mode='angular', apply_filter=False, filter_threshold=5000.0, + pixel_size=None, curtain_angle=None, reference_frame=None, + angular_width=8.0, notch_frac=0.02, dc_protect_frac=0.01, + angle_outlier_threshold=5.0, force=False, dry_run=False, workers=1, + ) + assert (output_dir / 'good_PyLisC_angular.mrc').exists() diff --git a/tests/unit/test_templates.py b/tests/unit/test_templates.py index 303752f..d584e02 100644 --- a/tests/unit/test_templates.py +++ b/tests/unit/test_templates.py @@ -1,26 +1,39 @@ -import pytest -from pylisc.templates import compile_template, extract_tilt_angle - - -def test_extracts_tilt_and_ignores_blank_fields(): - pattern = compile_template('{}_{position}_{}_{tilt}_{}_{}_{}_{}_{}.mrc') - filename = 'Position_012_003_-30.00_20240115_1_Fractions_motion_corrected.mrc' - assert extract_tilt_angle(filename, pattern) == pytest.approx(-30.00) - assert pattern.match(filename).group('position') == '012' - +''' +PyLisC: unit tests for frames mode filename template +''' -def test_no_match_raises(): - pattern = compile_template('{}_{tilt}.mrc') - with pytest.raises(ValueError): - extract_tilt_angle('completely_different_name.mrc', pattern) - - -def test_missing_tilt_field_raises(): - pattern = compile_template('{}_{position}.mrc') - with pytest.raises(ValueError): - extract_tilt_angle('a_b.mrc', pattern) +# Import external libraries +import pytest +# Import internal functions +from pylisc.templates import compile_template, extract_tilt_angle -def test_custom_delimiters_mix_separators(): - pattern = compile_template('{}-{}_{}-{tilt}.mrc', delimiters='_-') - assert extract_tilt_angle('sample-01_tilt-30.00.mrc', pattern) == pytest.approx(30.00) +class TestTemplates: + def test_extracts_tilt_and_ignores_blank_fields(self): + pattern = compile_template('{}_{position}_{}_{tilt}_{}_{}_{}_{}_{}.mrc') + filename = 'Position_012_003_-30.00_20240115_1_Fractions_motion_corrected.mrc' + assert extract_tilt_angle(filename, pattern) == pytest.approx(-30.00) + assert pattern.match(filename).group('position') == '012' + + def test_no_match_raises(self): + pattern = compile_template('{}_{tilt}.mrc') + with pytest.raises(ValueError): + extract_tilt_angle('completely_different_name.mrc', pattern) + + def test_missing_tilt_field_raises(self): + pattern = compile_template('{}_{position}.mrc') + with pytest.raises(ValueError): + extract_tilt_angle('a_b.mrc', pattern) + + def test_custom_delimiters_mix_separators(self): + pattern = compile_template('{}-{}_{}-{tilt}.mrc', delimiters='_-') + assert extract_tilt_angle('sample-01_tilt-30.00.mrc', pattern) == pytest.approx(30.00) + + def test_invalid_field_name_raises(self): + with pytest.raises(ValueError, match='invalid template field name'): + compile_template('{}_{1invalid}_{tilt}.mrc') + + def test_unparseable_tilt_raises(self): + pattern = compile_template('{}_{tilt}.mrc') + with pytest.raises(ValueError, match='could not parse tilt angle'): + extract_tilt_angle('foo_notanumber.mrc', pattern) \ No newline at end of file diff --git a/uv.lock b/uv.lock index 8664553..35979d1 100644 --- a/uv.lock +++ b/uv.lock @@ -53,6 +53,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, ] +[[package]] +name = "coverage" +version = "7.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f", size = 222608, upload-time = "2026-08-06T13:48:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c", size = 222940, upload-time = "2026-08-06T13:48:28.432Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082", size = 253985, upload-time = "2026-08-06T13:48:29.995Z" }, + { url = "https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac", size = 256492, upload-time = "2026-08-06T13:48:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734", size = 257837, upload-time = "2026-08-06T13:48:33.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d", size = 260152, upload-time = "2026-08-06T13:48:34.75Z" }, + { url = "https://files.pythonhosted.org/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf", size = 253978, upload-time = "2026-08-06T13:48:36.434Z" }, + { url = "https://files.pythonhosted.org/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b", size = 255846, upload-time = "2026-08-06T13:48:38.317Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25", size = 253808, upload-time = "2026-08-06T13:48:39.993Z" }, + { url = "https://files.pythonhosted.org/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303", size = 258081, upload-time = "2026-08-06T13:48:41.971Z" }, + { url = "https://files.pythonhosted.org/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f", size = 253624, upload-time = "2026-08-06T13:48:43.731Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5", size = 255280, upload-time = "2026-08-06T13:48:45.58Z" }, + { url = "https://files.pythonhosted.org/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7", size = 224768, upload-time = "2026-08-06T13:48:47.525Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425", size = 225259, upload-time = "2026-08-06T13:48:49.513Z" }, + { url = "https://files.pythonhosted.org/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8", size = 224684, upload-time = "2026-08-06T13:48:51.235Z" }, + { url = "https://files.pythonhosted.org/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8", size = 223338, upload-time = "2026-08-06T13:48:52.896Z" }, + { url = "https://files.pythonhosted.org/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a", size = 223609, upload-time = "2026-08-06T13:48:54.688Z" }, + { url = "https://files.pythonhosted.org/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b", size = 264970, upload-time = "2026-08-06T13:48:56.403Z" }, + { url = "https://files.pythonhosted.org/packages/bf/31/e66d98d6e9c7fcc88470f1e234eaf6b1950dc0dfbf797f7282c1c861da24/coverage-7.15.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5", size = 267088, upload-time = "2026-08-06T13:48:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba", size = 269508, upload-time = "2026-08-06T13:49:00.42Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982", size = 270629, upload-time = "2026-08-06T13:49:02.234Z" }, + { url = "https://files.pythonhosted.org/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c", size = 264043, upload-time = "2026-08-06T13:49:04.102Z" }, + { url = "https://files.pythonhosted.org/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57", size = 266963, upload-time = "2026-08-06T13:49:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26", size = 264569, upload-time = "2026-08-06T13:49:07.706Z" }, + { url = "https://files.pythonhosted.org/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429", size = 268299, upload-time = "2026-08-06T13:49:09.587Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017", size = 263413, upload-time = "2026-08-06T13:49:11.604Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839", size = 265725, upload-time = "2026-08-06T13:49:13.38Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85", size = 225079, upload-time = "2026-08-06T13:49:15.265Z" }, + { url = "https://files.pythonhosted.org/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e", size = 225911, upload-time = "2026-08-06T13:49:17.279Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e", size = 225219, upload-time = "2026-08-06T13:49:19.293Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9", size = 222604, upload-time = "2026-08-06T13:49:21.279Z" }, + { url = "https://files.pythonhosted.org/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a", size = 222944, upload-time = "2026-08-06T13:49:23.187Z" }, + { url = "https://files.pythonhosted.org/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54", size = 254050, upload-time = "2026-08-06T13:49:25.008Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f5/1f0f6f77698c3601ca0ae7431e34b24c62ca2f06fecb23b73ed1f651d2be/coverage-7.15.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d28a4a899354d0ea6214cc59b4fa19eefbce1b9ff1688ab579acf49e894bd3fb", size = 256967, upload-time = "2026-08-06T13:49:26.896Z" }, + { url = "https://files.pythonhosted.org/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed", size = 258587, upload-time = "2026-08-06T13:49:28.692Z" }, + { url = "https://files.pythonhosted.org/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce", size = 260785, upload-time = "2026-08-06T13:49:30.472Z" }, + { url = "https://files.pythonhosted.org/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c", size = 254545, upload-time = "2026-08-06T13:49:32.271Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced", size = 256682, upload-time = "2026-08-06T13:49:34.089Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800", size = 254560, upload-time = "2026-08-06T13:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3", size = 258792, upload-time = "2026-08-06T13:49:38.01Z" }, + { url = "https://files.pythonhosted.org/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768", size = 253968, upload-time = "2026-08-06T13:49:39.934Z" }, + { url = "https://files.pythonhosted.org/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753", size = 255893, upload-time = "2026-08-06T13:49:41.825Z" }, + { url = "https://files.pythonhosted.org/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2", size = 224768, upload-time = "2026-08-06T13:49:44.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809", size = 225242, upload-time = "2026-08-06T13:49:45.899Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d", size = 224674, upload-time = "2026-08-06T13:49:48.322Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d", size = 223333, upload-time = "2026-08-06T13:49:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26", size = 223630, upload-time = "2026-08-06T13:49:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645", size = 264489, upload-time = "2026-08-06T13:49:54.52Z" }, + { url = "https://files.pythonhosted.org/packages/10/2d/84b4b9e0e1dd6528a51920ff7031f35b789382e467a28ec6a5a578cb8812/coverage-7.15.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8df457da2249d3c75ca2e5e835d59c725abfe92d27fdff6cd99eed85b51d5e9a", size = 267567, upload-time = "2026-08-06T13:49:56.721Z" }, + { url = "https://files.pythonhosted.org/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624", size = 270123, upload-time = "2026-08-06T13:49:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa", size = 271107, upload-time = "2026-08-06T13:50:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f", size = 264955, upload-time = "2026-08-06T13:50:03.023Z" }, + { url = "https://files.pythonhosted.org/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f", size = 267949, upload-time = "2026-08-06T13:50:05.557Z" }, + { url = "https://files.pythonhosted.org/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67", size = 264421, upload-time = "2026-08-06T13:50:07.612Z" }, + { url = "https://files.pythonhosted.org/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc", size = 269121, upload-time = "2026-08-06T13:50:09.58Z" }, + { url = "https://files.pythonhosted.org/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88", size = 264565, upload-time = "2026-08-06T13:50:11.722Z" }, + { url = "https://files.pythonhosted.org/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc", size = 266522, upload-time = "2026-08-06T13:50:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a", size = 225068, upload-time = "2026-08-06T13:50:15.825Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b", size = 225895, upload-time = "2026-08-06T13:50:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278", size = 225213, upload-time = "2026-08-06T13:50:19.981Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, +] + [[package]] name = "cycler" version = "0.12.1" @@ -321,13 +390,14 @@ wheels = [ [[package]] name = "pylisc" -version = "2.3.0" +version = "2.4.0" source = { editable = "." } dependencies = [ { name = "loguru" }, { name = "matplotlib" }, { name = "mrcfile" }, { name = "numpy" }, + { name = "rich" }, { name = "scipy" }, { name = "tifffile" }, { name = "typer" }, @@ -336,6 +406,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "pytest" }, + { name = "pytest-cov" }, ] [package.metadata] @@ -344,13 +415,17 @@ requires-dist = [ { name = "matplotlib", specifier = ">=3.11.0" }, { name = "mrcfile", specifier = ">=1.5.4" }, { name = "numpy", specifier = ">=2.5.1" }, + { name = "rich", specifier = ">=15.0.0" }, { name = "scipy", specifier = ">=1.18.0" }, { name = "tifffile", specifier = ">=2026.7.14" }, { name = "typer", specifier = ">=0.27.0" }, ] [package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=9.1.1" }] +dev = [ + { name = "pytest", specifier = ">=9.1.1" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, +] [[package]] name = "pyparsing" @@ -377,6 +452,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0"