From fc6a1824a5b587d7881dac71e429ffcc4859f786 Mon Sep 17 00:00:00 2001 From: gillian Date: Fri, 20 Feb 2026 16:38:27 -0500 Subject: [PATCH 01/43] Added change log for future code from utils --- CHANGELOG.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5bd70e3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +## [Unreleased] - 2026-02-20 + +### Added +- **Dual-Threshold Detection:** Logic to capture the start and end of signals, not just the peak. +- **Signal Smoothing & Noise Filters:** Prevents detections from breaking into fragments and ignores short interference spikes. +- **Auto-Frequency Calculation:** Automatically adjusts bounding boxes to fit signal frequency ranges tightly. + +### Changed +- **Signal Power Detection:** Switched from raw signal strength to power for improved accuracy. +- **CLI Workflow:** `Clear` and `Remove` commands now modify files directly (in-place) to avoid redundant copies. +- **Metadata Logic:** Updated labels to show detection percentages and overhauled internal metadata cleaning. +- **Viewer UI:** Moved legend outside the plot, added a black background, and adjusted transparency for better spectrogram visibility. + +### Fixed +- Prevented redundant `_annotated` suffixes in file naming patterns. +- Simplified internal math to increase processing speed and precision. \ No newline at end of file From f7eedfa2bd6bca7cdd3d0563858542a651af03fc Mon Sep 17 00:00:00 2001 From: gillian Date: Mon, 23 Feb 2026 13:48:46 -0500 Subject: [PATCH 02/43] Annotate added to cli --- .../ria_toolkit_oss/annotate.py | 813 ++++++++++++++++++ 1 file changed, 813 insertions(+) create mode 100644 src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py diff --git a/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py b/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py new file mode 100644 index 0000000..74e1370 --- /dev/null +++ b/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py @@ -0,0 +1,813 @@ +"""Annotate command - Automatic detection and manual annotation management.""" + +import json +from pathlib import Path + +import click + +from ria_toolkit_oss.annotations import ( + annotate_with_cusum, + detect_signals_energy, + split_recording_annotations, + threshold_qualifier, +) +from ria_toolkit_oss.data import Annotation +from ria_toolkit_oss.data.recording import Recording +from ria_toolkit_oss.io import load_recording, to_blue, to_npy, to_sigmf, to_wav +from ria_toolkit_oss_cli.common import format_frequency, format_sample_count + + +def normalize_sigmf_path(filepath): + """Normalize SigMF path to base name without extension.""" + path = Path(filepath) + + # Handle .sigmf-data, .sigmf-meta, or .sigmf + if ".sigmf" in path.suffix: + # Remove the suffix to get base name + return path.with_suffix("") + else: + return path + + +def detect_input_format(filepath): + """Detect file format from extension.""" + path = Path(filepath) + ext = path.suffix.lower() + + if ext in [".sigmf-data", ".sigmf-meta"]: + return "sigmf" + elif path.name.endswith(".sigmf"): + return "sigmf" + elif ext == ".npy": + return "npy" + elif ext == ".wav": + return "wav" + elif ext == ".blue": + return "blue" + else: + raise click.ClickException(f"Unknown format for '{filepath}'. Supported: .sigmf, .npy, .wav, .blue") + + +def determine_output_path(input_path, output_path, fmt, quiet, overwrite): + input_path = Path(input_path) + + if output_path: + target = Path(output_path) + final_path = target + else: + annotated_name = f"{input_path.stem}_annotated" + target = input_path.with_name(f"{annotated_name}{input_path.suffix}") + + if fmt == "sigmf": + final_path = normalize_sigmf_path(target) + if not quiet: + click.echo(f"Saving SigMF metadata to: {final_path}") + else: + final_path = target + if not quiet: + click.echo(f"Saving to: {final_path}") + + if final_path.exists() and not overwrite and final_path != input_path: + click.echo(f"Error: {final_path} already exists. Use --overwrite to replace it.", err=True) + return None + + return final_path + + +def save_recording_auto(recording, output_path, input_path, quiet=False, overwrite=False): + """Save recording, auto-detecting format from extension. + + For SigMF: Only overwrites metadata file, data file is unchanged + For other formats: Creates _annotated copy by default, unless overwrite=True + """ + input_path = Path(input_path) + fmt = detect_input_format(input_path) + + # Determine output path + output_path = determine_output_path( + input_path=input_path, output_path=output_path, fmt=fmt, quiet=quiet, overwrite=overwrite + ) + + if fmt == "sigmf": + # Normalize path for SigMF + base_path = output_path + stem = base_path.name + parent = base_path.parent + + # For SigMF: only save metadata, copy data if needed + meta_path = parent / f"{stem}.sigmf-meta" + data_path = parent / f"{stem}.sigmf-data" + + # If output is different from input, copy data file + input_base = normalize_sigmf_path(input_path) + if input_base != base_path: + import shutil + + # Construct input data path correctly + # input_base is like /path/to/recording or /path/to/recording.sigmf + # We need /path/to/recording.sigmf-data + if str(input_base).endswith(".sigmf"): + input_data = Path(str(input_base).replace(".sigmf", ".sigmf-data")) + else: + input_data = input_base.parent / f"{input_base.name}.sigmf-data" + if not quiet: + click.echo(f" Copying: {data_path}") + shutil.copy2(input_data, data_path) + + # Always save metadata (this is the whole point) + to_sigmf(recording, filename=stem, path=parent, overwrite=True) + + if not quiet: + click.echo(f" Updated: {meta_path}") + if input_base != base_path: + click.echo(f" Created: {data_path}") + + elif fmt == "npy": + to_npy(recording, filename=output_path.stem, path=output_path.parent, overwrite=True) + if not quiet: + click.echo(f" Created: {output_path}") + elif fmt == "wav": + to_wav(recording, filename=output_path.stem, path=output_path.parent, overwrite=True) + if not quiet: + click.echo(f" Created: {output_path}") + elif fmt == "blue": + to_blue(recording, filename=output_path.stem, path=output_path.parent, overwrite=True) + if not quiet: + click.echo(f" Created: {output_path}") + + +def determine_frequency_bounds(recording: Recording, freq_lower, freq_upper): + # Handle frequency bounds + if (freq_lower is None) != (freq_upper is None): + raise click.ClickException("Must specify both --freq-lower and --freq-upper, or neither") + + if freq_lower is None: + # Default to full bandwidth + sample_rate = recording.metadata.get("sample_rate", 1) + center_freq = recording.metadata.get("center_frequency", 0) + freq_lower = center_freq - (sample_rate / 2) + freq_upper = center_freq + (sample_rate / 2) + freq_default = True + else: + freq_default = False + if freq_lower >= freq_upper: + raise click.ClickException( + f"Invalid frequency range: lower ({format_frequency(freq_lower)}) " + f"must be < upper ({format_frequency(freq_upper)})" + ) + + return freq_lower, freq_upper, freq_default + + +def get_indices_list(indices, recording: Recording): + if indices: + try: + indices_list = [int(idx.strip()) for idx in indices.split(",")] + # Validate indices + for idx in indices_list: + if idx < 0 or idx >= len(recording.annotations): + raise click.ClickException( + f"Invalid index {idx}. Recording has {len(recording.annotations)} annotation(s)" + ) + except ValueError as e: + raise click.ClickException(f"Invalid indices format. Expected comma-separated integers: {e}") + + return indices_list + else: + return None + + +# ============================================================================ +# Main command group +# ============================================================================ + + +@click.group() +def annotate(): + """Manage and auto-detect annotations on RF recordings. + + \b + MANUAL MANAGEMENT: + list - List all current annotations + add - Manually add a specific annotation + remove - Delete an annotation by its index + clear - Remove all annotations from the recording + + \b + DETECTION & SEPARATION: + energy - Auto-detect using energy-based thresholding + cusum - Auto-detect segments using signal state changes + threshold - Auto-detect samples above magnitude percentage + separate - Auto-detect parallel frequency-offset signals, split into sub-bands + + \b + File Path Handling: + - SigMF files: Pass .sigmf-data, .sigmf-meta, or base name + - Other formats: .npy, .wav, .blue files + + \b + Output Behavior: + - SigMF: Updates .sigmf-meta only (data unchanged), in-place + - Other: Creates _annotated copy unless --overwrite specified + """ + pass + + +# ============================================================================ +# List subcommand +# ============================================================================ + + +@annotate.command() +@click.argument("input", type=click.Path(exists=True)) +@click.option("--verbose", is_flag=True, help="Show detailed annotation info") +def list(input, verbose): + """List all annotations in a recording. + + \b + Examples: + utils annotate list recording.sigmf-data + utils annotate list signal.npy --verbose + """ + try: + recording = load_recording(input) + except Exception as e: + raise click.ClickException(f"Failed to load recording: {e}") + + if len(recording.annotations) == 0: + click.echo(f"No annotations in {Path(input).name}") + return + + click.echo(f"\nAnnotations in {Path(input).name}:") + for i, ann in enumerate(recording.annotations): + # Parse type from comment JSON + try: + comment_data = json.loads(ann.comment) + ann_type = comment_data.get("type", "unknown") + user_comment = comment_data.get("user_comment", "") + except (json.JSONDecodeError, TypeError): + ann_type = "unknown" + user_comment = ann.comment or "" + + # Basic info + freq_range = f"{format_frequency(ann.freq_lower_edge)} - {format_frequency(ann.freq_upper_edge)}" + click.echo( + f" [{i}] Samples {format_sample_count(ann.sample_start)}-" + f"{format_sample_count(ann.sample_start + ann.sample_count)}: {ann.label}" + ) + click.echo(f" Type: {ann_type}") + + if verbose: + if user_comment: + click.echo(f" Comment: {user_comment}") + click.echo(f" Frequency: {freq_range}") + if ann.detail: + click.echo(f" Detail: {ann.detail}") + + click.echo(f"\nTotal: {len(recording.annotations)} annotation(s)") + + +# ============================================================================ +# Add subcommand +# ============================================================================ + + +@annotate.command(context_settings={"max_content_width": 200}) +@click.argument("input", type=click.Path(exists=True)) +@click.option("--start", type=int, required=True, help="Start sample index") +@click.option("--count", type=int, required=True, help="Sample count") +@click.option("--label", type=str, required=True, help="Annotation label") +@click.option("--freq-lower", type=float, help="Lower frequency edge (Hz)") +@click.option("--freq-upper", type=float, help="Upper frequency edge (Hz)") +@click.option("--comment", type=str, help="Human-readable comment") +@click.option( + "--type", + "annotation_type", + type=click.Choice(["standalone", "parallel", "intersection"]), + default="standalone", + help="Annotation type", +) +@click.option("--output", "-o", type=click.Path(), help="Output file path") +@click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") +@click.option("--quiet", is_flag=True, help="Quiet mode") +def add(input, start, count, label, freq_lower, freq_upper, comment, annotation_type, output, overwrite, quiet): + """Add a manual annotation. + + \b + Examples: + utils annotate add file.npy --start 1000 --count 500 --label wifi + utils annotate add signal.sigmf-data --start 0 --count 1000 --label burst --comment "Strong signal" + """ + try: + recording = load_recording(input) + if not quiet: + click.echo(f"Loaded: {input}") + except Exception as e: + raise click.ClickException(f"Failed to load recording: {e}") + + # Validate sample range + n_samples = len(recording.data[0]) + if start < 0: + raise click.ClickException(f"--start must be >= 0, got {start}") + if count <= 0: + raise click.ClickException(f"--count must be > 0, got {count}") + if start + count > n_samples: + raise click.ClickException( + f"Invalid annotation range:\n" + f" Start: {start:,}\n" + f" Count: {count:,}\n" + f" End: {start + count:,}\n" + f"Recording only has {n_samples:,} samples" + ) + + # Handle frequency bounds + freq_lower, freq_upper, freq_default = determine_frequency_bounds( + recording=recording, freq_lower=freq_lower, freq_upper=freq_upper + ) + + # Build comment JSON + comment_data = {"type": annotation_type} + if comment: + comment_data["user_comment"] = comment + + # Create annotation + ann = Annotation( + sample_start=start, + sample_count=count, + freq_lower_edge=freq_lower, + freq_upper_edge=freq_upper, + label=label, + comment=json.dumps(comment_data), + detail={}, + ) + + recording._annotations.append(ann) + + if not quiet: + click.echo("\nAdding annotation:") + click.echo(f" Start: {format_sample_count(start)}") + click.echo(f" Count: {format_sample_count(count)} samples") + freq_str = ( + "full bandwidth" if freq_default else f"{format_frequency(freq_lower)} - {format_frequency(freq_upper)}" + ) + click.echo(f" Frequency: {freq_str}") + click.echo(f" Label: {label}") + click.echo(f" Type: {annotation_type}") + if comment: + click.echo(f" Comment: {comment}") + + try: + save_recording_auto(recording, output, input, quiet, overwrite) + if not quiet: + click.echo(" ✓ Saved") + except Exception as e: + raise click.ClickException(f"Failed to save: {e}") + + +# ============================================================================ +# Remove subcommand +# ============================================================================ + + +@annotate.command(context_settings={"max_content_width": 200}) +@click.argument("input", type=click.Path(exists=True)) +@click.argument("index", type=int) +@click.option("--output", "-o", type=click.Path(), help="Output file path") +@click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") +@click.option("--quiet", is_flag=True, help="Quiet mode") +def remove(input, index, output, overwrite, quiet): + """Remove annotation by index. + + Use 'utils annotate list' to see annotation indices. + + \b + Examples: + utils annotate remove signal.sigmf-data 2 + utils annotate remove file.npy 0 + """ + try: + recording = load_recording(input) + if not quiet: + click.echo(f"Loaded: {input}") + except Exception as e: + raise click.ClickException(f"Failed to load recording: {e}") + + if index < 0 or index >= len(recording.annotations): + raise click.ClickException( + f"Cannot remove annotation at index {index}\n" + f"Recording has {len(recording.annotations)} annotation(s) (indices 0-{len(recording.annotations)-1})" + ) + + removed_ann = recording.annotations[index] + recording._annotations.pop(index) + + if not quiet: + click.echo(f"\nRemoving annotation [{index}]:") + click.echo( + f" Removed: samples {format_sample_count(removed_ann.sample_start)}-" + f"{format_sample_count(removed_ann.sample_start + removed_ann.sample_count)} ({removed_ann.label})" + ) + + try: + save_recording_auto(recording, output_path=input, input_path=input, quiet=quiet, overwrite=True) + if not quiet: + click.echo(" ✓ Saved") + except Exception as e: + raise click.ClickException(f"Failed to save: {e}") + + +# ============================================================================ +# Clear subcommand +# ============================================================================ + + +@annotate.command(context_settings={"max_content_width": 175}) +@click.argument("input", type=click.Path(exists=True)) +@click.option("--output", "-o", type=click.Path(), help="Output file path") +@click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") +@click.option("--force", is_flag=True, help="Skip confirmation") +@click.option("--quiet", is_flag=True, help="Quiet mode") +def clear(input, output, overwrite, force, quiet): + """Clear all annotations. + + \b + Examples: + utils annotate clear signal.sigmf-data + utils annotate clear file.npy --force + """ + try: + recording = load_recording(input) + if not quiet: + click.echo(f"Loaded: {input}") + except Exception as e: + raise click.ClickException(f"Failed to load recording: {e}") + + count_before = len(recording.annotations) + + if count_before == 0: + if not quiet: + click.echo("No annotations to clear") + return + + # Confirm unless --force + if not force and not quiet: + click.echo(f"\nWarning: This will remove all {count_before} annotation(s)") + click.confirm("Continue?", abort=True) + + recording._annotations = [] + + if not quiet: + click.echo(f"\nCleared {count_before} annotation(s)") + + recording._annotations = [] + + try: + save_recording_auto(recording, output_path=input, input_path=input, quiet=quiet, overwrite=True) + if not quiet: + click.echo(" ✓ Saved") + except Exception as e: + raise click.ClickException(f"Failed to save: {e}") + + +# ============================================================================ +# Energy detection subcommand +# ============================================================================ + + +@annotate.command(context_settings={"max_content_width": 200}) +@click.argument("input", type=click.Path(exists=True)) +@click.option("--label", type=str, default="signal", help="Annotation label") +@click.option("--threshold", type=float, default=1.2, help="Threshold multiplier above noise floor") +@click.option("--segments", type=int, default=10, help="Number of segments for noise estimation") +@click.option("--window-size", type=int, default=200, help="Smoothing window size") +@click.option("--min-distance", type=int, default=5000, help="Min distance between detections") +@click.option( + "--freq-method", + type=click.Choice(["nbw", "obw", "full-detected", "full-bandwidth"]), + default="nbw", + help="Frequency bounding method", +) +@click.option("--nfft", type=int, default=None, help="FFT size for frequency calculation") +@click.option("--obw-power", type=float, default=0.99, help="Power percentage for OBW/NBW (0.98-0.9999)") +@click.option( + "--type", + "annotation_type", + type=click.Choice(["standalone", "parallel", "intersection"]), + default="standalone", + help="Annotation type", +) +@click.option("--output", "-o", type=click.Path(), help="Output file path") +@click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") +@click.option("--quiet", is_flag=True, help="Quiet mode") +def energy( + input, + label, + threshold, + segments, + window_size, + min_distance, + freq_method, + nfft, + obw_power, + annotation_type, + output, + overwrite, + quiet, +): + """Auto-detect signals using energy-based method. + + Detects bursts based on energy above noise floor. Best for bursty signals + and intermittent transmissions. + + \b + Frequency Bounding Methods: + nbw - Nominal bandwidth (default, best for real signals) + obw - Occupied bandwidth (more conservative, includes sidelobes) + full-detected - Lowest to highest spectral component + full-bandwidth - Entire Nyquist span + + \b + Examples: + utils annotate energy capture.sigmf-data --label burst + utils annotate energy signal.npy --threshold 1.5 --min-distance 10000 + utils annotate energy signal.sigmf-data --freq-method obw + utils annotate energy signal.sigmf-data --freq-method full-detected + + """ + try: + recording = load_recording(input) + if not quiet: + click.echo(f"Loaded: {input}") + except Exception as e: + raise click.ClickException(f"Failed to load recording: {e}") + + if not quiet: + click.echo("\nDetecting signals using energy-based method...") + click.echo(" Time detection:") + click.echo(f" Segments: {segments}") + click.echo(f" Threshold: {threshold}x noise floor") + click.echo(f" Window size: {window_size} samples") + click.echo(f" Min distance: {min_distance} samples") + click.echo(f" Frequency bounds: {freq_method}") + + try: + initial_count = len(recording.annotations) + recording = detect_signals_energy( + recording, + k=segments, + threshold_factor=threshold, + window_size=window_size, + min_distance=min_distance, + label=label, + annotation_type=annotation_type, + freq_method=freq_method, + nfft=nfft, + obw_power=obw_power, + ) + added = len(recording.annotations) - initial_count + + if not quiet: + click.echo(f" ✓ Added {added} annotation(s)") + + save_recording_auto(recording, output, input, quiet, overwrite) + if not quiet: + click.echo(" ✓ Saved") + except Exception as e: + raise click.ClickException(f"Energy detection failed: {e}") + + +# ============================================================================ +# CUSUM detection subcommand +# ============================================================================ + + +@annotate.command() +@click.argument("input", type=click.Path(exists=True)) +@click.option("--label", type=str, default="segment", help="Annotation label") +@click.option("--min-duration", type=float, default=5.0, help="Min duration in ms (prevents over-segmentation)") +@click.option("--window-size", type=int, default=1, help="Smoothing window size") +@click.option("--tolerance", type=int, default=-1, help="Sample tolerance for merging") +@click.option( + "--type", + "annotation_type", + type=click.Choice(["standalone", "parallel", "intersection"]), + default="standalone", + help="Annotation type", +) +@click.option("--output", "-o", type=click.Path(), help="Output file path") +@click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") +@click.option("--quiet", is_flag=True, help="Quiet mode") +def cusum(input, label, min_duration, window_size, tolerance, annotation_type, output, overwrite, quiet): + """Auto-detect segments using CUSUM method. + + Detects signal state changes (on/off, amplitude transitions). Best for + segmenting continuous signals. + + IMPORTANT: Always specify --min-duration to prevent excessive segmentation. + + \b + Examples: + utils annotate cusum signal.sigmf-data --min-duration 5.0 + utils annotate cusum data.npy --min-duration 10.0 --label state + """ + try: + recording = load_recording(input) + if not quiet: + click.echo(f"Loaded: {input}") + except Exception as e: + raise click.ClickException(f"Failed to load recording: {e}") + + if not quiet: + click.echo("\nDetecting segments using CUSUM...") + click.echo(f" Min duration: {min_duration} ms") + if window_size != 1: + click.echo(f" Window size: {window_size} samples") + + try: + initial_count = len(recording.annotations) + recording = annotate_with_cusum( + recording, + label=label, + window_size=window_size, + min_duration=min_duration, + tolerance=tolerance, + annotation_type=annotation_type, + ) + added = len(recording.annotations) - initial_count + + if not quiet: + click.echo(f" ✓ Added {added} annotation(s)") + + save_recording_auto(recording, output, input, quiet, overwrite) + if not quiet: + click.echo(" ✓ Saved") + except Exception as e: + raise click.ClickException(f"CUSUM detection failed: {e}") + + +# ============================================================================ +# Threshold detection subcommand +# ============================================================================ + + +@annotate.command() +@click.argument("input", type=click.Path(exists=True)) +@click.option("--threshold", type=float, required=True, help="Threshold (0.0-1.0, fraction of max magnitude)") +@click.option("--label", type=str, default=None, help="Annotation label") +@click.option("--window-size", type=int, default=1024, help="Smoothing window size") +@click.option( + "--type", + "annotation_type", + type=click.Choice(["standalone", "parallel", "intersection"]), + default="standalone", + help="Annotation type", +) +@click.option("--output", "-o", type=click.Path(), help="Output file path") +@click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") +@click.option("--quiet", is_flag=True, help="Quiet mode") +def threshold(input, threshold, label, window_size, annotation_type, output, overwrite, quiet): + """Auto-detect signals using threshold method. + + Detects samples above a percentage of maximum magnitude. Best for simple + power-based detection. + + \b + Examples: + utils annotate threshold signal.sigmf-data --threshold 0.7 --label wifi + utils annotate threshold data.npy --threshold 0.5 --window-size 2048 + """ + if not (0.0 <= threshold <= 1.0): + raise click.ClickException(f"--threshold must be between 0.0 and 1.0, got {threshold}") + + try: + recording = load_recording(input) + if not quiet: + click.echo(f"Loaded: {input}") + except Exception as e: + raise click.ClickException(f"Failed to load recording: {e}") + + if not quiet: + click.echo("\nDetecting signals using threshold qualifier...") + click.echo(f" Threshold: {threshold * 100:.1f}% of max magnitude") + click.echo(f" Window size: {window_size} samples") + + try: + initial_count = len(recording.annotations) + recording = threshold_qualifier( + recording, + threshold=threshold, + window_size=window_size, + label=label, + annotation_type=annotation_type, + ) + added = len(recording.annotations) - initial_count + + if not quiet: + click.echo(f" ✓ Added {added} annotation(s)") + + save_recording_auto(recording, output, input, quiet, overwrite) + if not quiet: + click.echo(" ✓ Saved") + except Exception as e: + raise click.ClickException(f"Threshold detection failed: {e}") + + +# ============================================================================ +# Separate subcommand (Phase 2: Parallel signal separation) +# ============================================================================ + + +@annotate.command() +@click.argument("input", type=click.Path(exists=True)) +@click.option("--indices", type=str, help="Comma-separated annotation indices to split (default: all)") +@click.option("--nfft", type=int, default=65536, help="FFT size for spectral analysis") +@click.option("--noise-threshold-db", type=float, help="Noise floor threshold in dB (auto-estimated if not specified)") +@click.option("--min-component-bw", type=float, default=50e3, help="Min component bandwidth in Hz") +@click.option("--output", "-o", type=click.Path(), help="Output file path") +@click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") +@click.option("--quiet", is_flag=True, help="Quiet mode") +@click.option("--verbose", is_flag=True, help="Verbose output (show detected components)") +def separate(input, indices, nfft, noise_threshold_db, min_component_bw, output, overwrite, quiet, verbose): + """ + Auto-detect parallel frequency-offset signals and split into sub-bands. + + Provides methods to detect and separate overlapping frequency-domain signals + that occupy the same time window but different frequency bands. + + Detects multiple frequency components within single annotations and splits + them into separate annotations. Uses spectral peak detection with dual + bandwidth estimation. + + \b + Key Features: + - Spectral peak detection for frequency components + - Auto noise floor estimation (or user-specified) + - Dual bandwidth estimation: -3dB primary, cumulative power fallback + - Handles narrowband and wide signals (OFDM) + + \b + Examples: + utils annotate separate capture.sigmf-data + utils annotate separate signal.npy --indices 0,1,2 + utils annotate separate data.sigmf-data --noise-threshold-db -70 + utils annotate separate signal.npy --min-component-bw 100000 + + """ + try: + recording = load_recording(input) + if not quiet: + click.echo(f"Loaded: {input}") + except Exception as e: + raise click.ClickException(f"Failed to load recording: {e}") + + # Parse indices if specified + indices_list = get_indices_list(indices=indices, recording=recording) + + if len(recording.annotations) == 0: + if not quiet: + click.echo("No annotations to split") + return + + if not quiet: + click.echo("\nSplitting annotations by frequency components...") + click.echo(f" Input annotations: {len(recording.annotations)}") + if indices_list: + click.echo(f" Splitting indices: {indices_list}") + click.echo(f" FFT size: {nfft}") + if noise_threshold_db is not None: + click.echo(f" Noise threshold: {noise_threshold_db} dB") + else: + click.echo(" Noise threshold: auto-estimated") + click.echo(f" Min component BW: {format_frequency(min_component_bw)}") + + try: + initial_count = len(recording.annotations) + + recording = split_recording_annotations( + recording, + indices=indices_list, + nfft=nfft, + noise_threshold_db=noise_threshold_db, + min_component_bw=min_component_bw, + ) + + final_count = len(recording.annotations) + added = final_count - initial_count + + if not quiet: + click.echo(f" ✓ Output annotations: {final_count} ({'+' if added >= 0 else ''}{added} change)") + if verbose and added > 0: + click.echo("\n Details:") + for i in range(initial_count, final_count): + ann = recording.annotations[i] + freq_range = f"{format_frequency(ann.freq_lower_edge)} - {format_frequency(ann.freq_upper_edge)}" + click.echo( + f" [{i}] samples {format_sample_count(ann.sample_start)}-" + f"{format_sample_count(ann.sample_start + ann.sample_count)}: {freq_range}" + ) + + save_recording_auto(recording, output, input, quiet, overwrite) + if not quiet: + click.echo(" ✓ Saved") + except Exception as e: + raise click.ClickException(f"Spectral separation failed: {e}") From 4ee8ee5fe0ff06dc0cca03b0268016715a126170 Mon Sep 17 00:00:00 2001 From: gillian Date: Mon, 23 Feb 2026 14:00:06 -0500 Subject: [PATCH 03/43] Moving from utils --- src/ria_toolkit_oss/annotations/__init__.py | 55 +++++++++ src/ria_toolkit_oss/data/annotation.py | 128 ++++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 src/ria_toolkit_oss/annotations/__init__.py create mode 100644 src/ria_toolkit_oss/data/annotation.py diff --git a/src/ria_toolkit_oss/annotations/__init__.py b/src/ria_toolkit_oss/annotations/__init__.py new file mode 100644 index 0000000..1d0a6df --- /dev/null +++ b/src/ria_toolkit_oss/annotations/__init__.py @@ -0,0 +1,55 @@ + +""" +The annotations package contains tools and utilities for creating, managing, and processing annotations. + +Provides automatic annotation generation using various signal detection algorithms: +- Energy-based detection (detect_signals_energy) +- CUSUM-based segmentation (annotate_with_cusum) +- Threshold-based qualification (threshold_qualifier) +- Signal isolation and extraction (isolate_signal) +- Occupied bandwidth analysis (calculate_occupied_bandwidth, calculate_nominal_bandwidth) + +All detection functions return Recording objects with added annotations. +""" + +__all__ = [ + # Energy-based detection + "detect_signals_energy", + "calculate_occupied_bandwidth", + "calculate_nominal_bandwidth", + "calculate_full_detected_bandwidth", + "annotate_with_obw", + # CUSUM detection + "annotate_with_cusum", + # Threshold detection + "threshold_qualifier", + # Parallel signal separation (Phase 2) + "find_spectral_components", + "split_annotation_by_components", + "split_recording_annotations", + # Signal isolation + "isolate_signal", + # Annotation transforms + "remove_contained_boxes", + "is_annotation_contained", + # Dataset creation + "qualify_slice_from_annotations", +] + +from .annotation_transforms import is_annotation_contained, remove_contained_boxes +from .cusum_annotator import annotate_with_cusum +from .energy_detector import ( + annotate_with_obw, + calculate_full_detected_bandwidth, + calculate_nominal_bandwidth, + calculate_occupied_bandwidth, + detect_signals_energy, +) +from .parallel_signal_separator import ( + find_spectral_components, + split_annotation_by_components, + split_recording_annotations, +) +from .qualify_slice import qualify_slice_from_annotations +from .signal_isolation import isolate_signal +from .threshold_qualifier import threshold_qualifier \ No newline at end of file diff --git a/src/ria_toolkit_oss/data/annotation.py b/src/ria_toolkit_oss/data/annotation.py new file mode 100644 index 0000000..1182480 --- /dev/null +++ b/src/ria_toolkit_oss/data/annotation.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import json +from typing import Any, Optional + +from sigmf import SigMFFile + + +class Annotation: + """Signal annotations are labels or additional information associated with specific data points or segments within + a signal. These annotations could be used for tasks like supervised learning, where the goal is to train a model + to recognize patterns or characteristics in the signal associated with these annotations. + + Annotations can be used to label interesting points in your recording. + + :param sample_start: The index of the starting sample of the annotation. + :type sample_start: int + :param sample_count: The index of the ending sample of the annotation, inclusive. + :type sample_count: int + :param freq_lower_edge: The lower frequency of the annotation. + :type freq_lower_edge: float + :param freq_upper_edge: The upper frequency of the annotation. + :type freq_upper_edge: float + :param label: The label that will be displayed with the bounding box in compatible viewers including IQEngine. + Defaults to an emtpy string. + :type label: str, optional + :param comment: A human-readable comment. Defaults to an empty string. + :type comment: str, optional + :param detail: A dictionary of user defined annotation-specific metadata. Defaults to None. + :type detail: dict, optional + """ + + def __init__( + self, + sample_start: int, + sample_count: int, + freq_lower_edge: float, + freq_upper_edge: float, + label: Optional[str] = "", + comment: Optional[str] = "", + detail: Optional[dict] = None, + ): + """Initialize a new Annotation instance.""" + self.sample_start = int(sample_start) + self.sample_count = int(sample_count) + self.freq_lower_edge = float(freq_lower_edge) + self.freq_upper_edge = float(freq_upper_edge) + self.label = str(label) + self.comment = str(comment) + + if detail is None: + self.detail = {} + elif not _is_jsonable(detail): + raise ValueError(f"Detail object is not json serializable: {detail}") + else: + self.detail = detail + + def is_valid(self) -> bool: + """ + Check that the annotation sample count is > 0 and the freq_lower_edge 0 and self.freq_lower_edge < self.freq_upper_edge + + def overlap(self, other): + """ + Quantify how much the bounding box in this annotation overlaps with another annotation. + + :param other: The other annotation. + :type other: Annotation + + :returns: The area of the overlap in samples*frequency, or 0 if they do not overlap.""" + + sample_overlap_start = max(self.sample_start, other.sample_start) + sample_overlap_end = min(self.sample_start + self.sample_count, other.sample_start + other.sample_count) + + freq_overlap_start = max(self.freq_lower_edge, other.freq_lower_edge) + freq_overlap_end = min(self.freq_upper_edge, other.freq_upper_edge) + + if freq_overlap_start >= freq_overlap_end or sample_overlap_start >= sample_overlap_end: + return 0 + else: + return (sample_overlap_end - sample_overlap_start) * (freq_overlap_end - freq_overlap_start) + + def area(self): + """ + The 'area' of the bounding box, samples*frequency. + Useful to quantify annotation size. + + :returns: sample length multiplied by bandwidth.""" + + return self.sample_count * (self.freq_upper_edge - self.freq_lower_edge) + + def __eq__(self, other: Annotation) -> bool: + return self.__dict__ == other.__dict__ + + def to_sigmf_format(self): + """ + Returns a JSON dictionary representing this annotation formatted to be saved in a .sigmf-meta file. + """ + + annotation_dict = {SigMFFile.START_INDEX_KEY: self.sample_start, SigMFFile.LENGTH_INDEX_KEY: self.sample_count} + + annotation_dict["metadata"] = { + SigMFFile.LABEL_KEY: self.label, + SigMFFile.COMMENT_KEY: self.comment, + SigMFFile.FHI_KEY: self.freq_upper_edge, + SigMFFile.FLO_KEY: self.freq_lower_edge, + "ria:detail": self.detail, + } + + if _is_jsonable(annotation_dict): + return annotation_dict + else: + raise ValueError("Annotation dictionary was not json serializable.") + + +def _is_jsonable(x: Any) -> bool: + """ + :return: True if x is JSON serializable, False otherwise. + """ + try: + json.dumps(x) + return True + except (TypeError, OverflowError): + return False From 5c0c20619fcc008234a5b070c5c3bcb789811247 Mon Sep 17 00:00:00 2001 From: gillian Date: Mon, 23 Feb 2026 14:00:59 -0500 Subject: [PATCH 04/43] Moving over from utils --- src/ria_toolkit_oss/data/__init__.py | 8 + src/ria_toolkit_oss/data/recording.py | 853 ++++++++++++++++++++++++++ 2 files changed, 861 insertions(+) create mode 100644 src/ria_toolkit_oss/data/__init__.py create mode 100644 src/ria_toolkit_oss/data/recording.py diff --git a/src/ria_toolkit_oss/data/__init__.py b/src/ria_toolkit_oss/data/__init__.py new file mode 100644 index 0000000..b72f469 --- /dev/null +++ b/src/ria_toolkit_oss/data/__init__.py @@ -0,0 +1,8 @@ +""" +The Data package contains abstract data types tailored for radio machine learning, such as ``Recording``, as well +as the abstract interfaces for the radio dataset and radio dataset builder framework. +""" + +__all__ = ["Annotation", "Recording"] +from .annotation import Annotation +from .recording import Recording diff --git a/src/ria_toolkit_oss/data/recording.py b/src/ria_toolkit_oss/data/recording.py new file mode 100644 index 0000000..50e03ee --- /dev/null +++ b/src/ria_toolkit_oss/data/recording.py @@ -0,0 +1,853 @@ +from __future__ import annotations + +import copy +import hashlib +import json +import os +import re +import time +import warnings +from typing import Any, Iterator, Optional + +import numpy as np +from numpy.typing import ArrayLike + +from utils.data.annotation import Annotation + +PROTECTED_KEYS = ["rec_id", "timestamp"] + + +class Recording: + """Tape of complex IQ (in-phase and quadrature) samples with associated metadata and annotations. + + Recording data is a complex array of shape C x N, where C is the number of channels + and N is the number of samples in each channel. + + Metadata is stored in a dictionary of key value pairs, + to include information such as sample_rate and center_frequency. + + Annotations are a list of :ref:`Annotation `, + defining bounding boxes in time and frequency with labels and metadata. + + Here, signal data is represented as a NumPy array. This class is then extended in the RIA Backends to provide + support for different data structures, such as Tensors. + + Recordings are long-form tapes can be obtained either from a software-defined radio (SDR) or generated + synthetically. Then, machine learning datasets are curated from collection of recordings by segmenting these + longer-form tapes into shorter units called slices. + + All recordings are assigned a unique 64-character recording ID, ``rec_id``. If this field is missing from the + provided metadata, a new ID will be generated upon object instantiation. + + :param data: Signal data as a tape IQ samples, either C x N complex, where C is the number of + channels and N is number of samples in the signal. If data is a one-dimensional array of complex samples with + length N, it will be reshaped to a two-dimensional array with dimensions 1 x N. + :type data: array_like + + :param metadata: Additional information associated with the recording. + :type metadata: dict, optional + :param annotations: A collection of ``Annotation`` objects defining bounding boxes. + :type annotations: list of Annotations, optional + + :param dtype: Explicitly specify the data-type of the complex samples. Must be a complex NumPy type, such as + ``np.complex64`` or ``np.complex128``. Default is None, in which case the type is determined implicitly. If + ``data`` is a NumPy array, the Recording will use the dtype of ``data`` directly without any conversion. + :type dtype: numpy dtype object, optional + :param timestamp: The timestamp when the recording data was generated. If provided, it should be a float or integer + representing the time in seconds since epoch (e.g., ``time.time()``). Only used if the `timestamp` field is not + present in the provided metadata. + :type dtype: float or int, optional + + :raises ValueError: If data is not complex 1xN or CxN. + :raises ValueError: If metadata is not a python dict. + :raises ValueError: If metadata is not json serializable. + :raises ValueError: If annotations is not a list of valid annotation objects. + + **Examples:** + + >>> import numpy + >>> from utils.data import Recording, Annotation + + >>> # Create an array of complex samples, just 1s in this case. + >>> samples = numpy.ones(10000, dtype=numpy.complex64) + + >>> # Create a dictionary of relevant metadata. + >>> sample_rate = 1e6 + >>> center_frequency = 2.44e9 + >>> metadata = { + ... "sample_rate": sample_rate, + ... "center_frequency": center_frequency, + ... "author": "me", + ... } + + >>> # Create an annotation for the annotations list. + >>> annotations = [ + ... Annotation( + ... sample_start=0, + ... sample_count=1000, + ... freq_lower_edge=center_frequency - (sample_rate / 2), + ... freq_upper_edge=center_frequency + (sample_rate / 2), + ... label="example", + ... ) + ... ] + + >>> # Store samples, metadata, and annotations together in a convenient object. + >>> recording = Recording(data=samples, metadata=metadata, annotations=annotations) + >>> print(recording.metadata) + {'sample_rate': 1000000.0, 'center_frequency': 2440000000.0, 'author': 'me'} + >>> print(recording.annotations[0].label) + 'example' + """ + + def __init__( # noqa C901 + self, + data: ArrayLike | list[list], + metadata: Optional[dict[str, any]] = None, + dtype: Optional[np.dtype] = None, + timestamp: Optional[float | int] = None, + annotations: Optional[list[Annotation]] = None, + ): + + data_arr = np.asarray(data) + + if np.iscomplexobj(data_arr): + # Expect C x N + if data_arr.ndim == 1: + self._data = np.expand_dims(data_arr, axis=0) # N -> 1 x N + elif data_arr.ndim == 2: + self._data = data_arr + else: + raise ValueError("Complex data must be C x N.") + + else: + raise ValueError("Input data must be complex.") + + if dtype is not None: + self._data = self._data.astype(dtype) + + assert np.iscomplexobj(self._data) + + if metadata is None: + self._metadata = {} + elif isinstance(metadata, dict): + self._metadata = metadata + else: + raise ValueError(f"Metadata must be a python dict, but was {type(metadata)}.") + + if not _is_jsonable(metadata): + raise ValueError("Value must be JSON serializable.") + + if "timestamp" not in self.metadata: + if timestamp is not None: + if not isinstance(timestamp, (int, float)): + raise ValueError(f"timestamp must be int or float, not {type(timestamp)}") + self._metadata["timestamp"] = timestamp + else: + self._metadata["timestamp"] = time.time() + else: + if not isinstance(self._metadata["timestamp"], (int, float)): + raise ValueError("timestamp must be int or float, not ", type(self._metadata["timestamp"])) + + if "rec_id" not in self.metadata: + self._metadata["rec_id"] = generate_recording_id(data=self.data, timestamp=self._metadata["timestamp"]) + + if annotations is None: + self._annotations = [] + elif isinstance(annotations, list): + self._annotations = annotations + else: + raise ValueError("Annotations must be a list or None.") + + if not all(isinstance(annotation, Annotation) for annotation in self._annotations): + raise ValueError("All elements in self._annotations must be of type Annotation.") + + self._index = 0 + + @property + def data(self) -> np.ndarray: + """ + :return: Recording data, as a complex array. + :type: np.ndarray + + .. note:: + + For recordings with more than 1,024 samples, this property returns a read-only view of the data. + + .. note:: + + To access specific samples, consider indexing the object directly with ``rec[c, n]``. + """ + if self._data.size > 1024: + # Returning a read-only view prevents mutation at a distance while maintaining performance. + v = self._data.view() + v.setflags(write=False) + return v + else: + return self._data.copy() + + @property + def metadata(self) -> dict: + """ + :return: Dictionary of recording metadata. + :type: dict + """ + return self._metadata.copy() + + @property + def annotations(self) -> list[Annotation]: + """ + :return: List of recording annotations + :type: list of Annotation objects + """ + return self._annotations.copy() + + @property + def shape(self) -> tuple[int]: + """ + :return: The shape of the data array. + :type: tuple of ints + """ + return np.shape(self.data) + + @property + def n_chan(self) -> int: + """ + :return: The number of channels in the recording. + :type: int + """ + return self.shape[0] + + @property + def rec_id(self) -> str: + """ + :return: Recording ID. + :type: str + """ + return self.metadata["rec_id"] + + @property + def dtype(self) -> str: + """ + :return: Data-type of the data array's elements. + :type: numpy dtype object + """ + return self.data.dtype + + @property + def timestamp(self) -> float | int: + """ + :return: Recording timestamp (time in seconds since epoch). + :type: float or int + """ + return self.metadata["timestamp"] + + @property + def sample_rate(self) -> float | None: + """ + :return: Sample rate of the recording, or None if 'sample_rate' is not in metadata. + :type: str + """ + return self.metadata.get("sample_rate") + + @sample_rate.setter + def sample_rate(self, sample_rate: float | int) -> None: + """Set the sample rate of the recording. + + :param sample_rate: The sample rate of the recording. + :type sample_rate: float or int + + :return: None + """ + self.add_to_metadata(key="sample_rate", value=sample_rate) + + def astype(self, dtype: np.dtype) -> Recording: + """Copy of the recording, data cast to a specified type. + + .. todo: This method is not yet implemented. + + :param dtype: Data-type to which the array is cast. Must be a complex scalar type, such as ``np.complex64`` or + ``np.complex128``. + :type dtype: NumPy data type, optional + + .. note: Casting to a data type with less precision can risk losing data by truncating or rounding values, + potentially resulting in a loss of accuracy and significant information. + + :return: A new recording with the same metadata and data, with dtype. + + TODO: Add example usage. + """ + # Rather than check for a valid datatype, let's cast and check the result. This makes it easier to provide + # cross-platform support where the types are aliased across platforms. + with warnings.catch_warnings(): + warnings.simplefilter("ignore") # Casting may generate user warnings. E.g., complex -> real + data = self.data.astype(dtype) + + if np.iscomplexobj(data): + return Recording(data=data, metadata=self.metadata, annotations=self.annotations) + else: + raise ValueError("dtype must be a complex number scalar type.") + + def add_to_metadata(self, key: str, value: Any) -> None: + """Add a new key-value pair to the recording metadata. + + :param key: New metadata key, must be snake_case. + :type key: str + :param value: Corresponding metadata value. + :type value: any + + :raises ValueError: If key is already in metadata or if key is not a valid metadata key. + :raises ValueError: If value is not JSON serializable. + + :return: None. + + **Examples:** + + Create a recording and add metadata: + + >>> import numpy + >>> from utils.data import Recording + >>> + >>> samples = numpy.ones(10000, dtype=numpy.complex64) + >>> metadata = { + >>> "sample_rate": 1e6, + >>> "center_frequency": 2.44e9, + >>> } + >>> + >>> recording = Recording(data=samples, metadata=metadata) + >>> print(recording.metadata) + {'sample_rate': 1000000.0, + 'center_frequency': 2440000000.0, + 'timestamp': 17369..., + 'rec_id': 'fda0f41...'} + >>> + >>> recording.add_to_metadata(key="author", value="me") + >>> print(recording.metadata) + {'sample_rate': 1000000.0, + 'center_frequency': 2440000000.0, + 'author': 'me', + 'timestamp': 17369..., + 'rec_id': 'fda0f41...'} + """ + if key in self.metadata: + raise ValueError( + f"Key {key} already in metadata. Use Recording.update_metadata() to modify existing fields." + ) + + if not _is_valid_metadata_key(key): + raise ValueError(f"Invalid metadata key: {key}.") + + if not _is_jsonable(value): + raise ValueError("Value must be JSON serializable.") + + self._metadata[key] = value + + def update_metadata(self, key: str, value: Any) -> None: + """Update the value of an existing metadata key, + or add the key value pair if it does not already exist. + + :param key: Existing metadata key. + :type key: str + :param value: New value to enter at key. + :type value: any + + :raises ValueError: If value is not JSON serializable + :raises ValueError: If key is protected. + + :return: None. + + **Examples:** + + Create a recording and update metadata: + + >>> import numpy + >>> from utils.data import Recording + + >>> samples = numpy.ones(10000, dtype=numpy.complex64) + >>> metadata = { + >>> "sample_rate": 1e6, + >>> "center_frequency": 2.44e9, + >>> "author": "me" + >>> } + + >>> recording = Recording(data=samples, metadata=metadata) + >>> print(recording.metadata) + {'sample_rate': 1000000.0, + 'center_frequency': 2440000000.0, + 'author': "me", + 'timestamp': 17369... + 'rec_id': 'fda0f41...'} + + >>> recording.update_metadata(key="author", value=you") + >>> print(recording.metadata) + {'sample_rate': 1000000.0, + 'center_frequency': 2440000000.0, + 'author': "you", + 'timestamp': 17369... + 'rec_id': 'fda0f41...'} + """ + if key not in self.metadata: + self.add_to_metadata(key=key, value=value) + + if not _is_jsonable(value): + raise ValueError("Value must be JSON serializable.") + + if key in PROTECTED_KEYS: # Check protected keys. + raise ValueError(f"Key {key} is protected and cannot be modified or removed.") + + else: + self._metadata[key] = value + + def remove_from_metadata(self, key: str): + """ + Remove a key from the recording metadata. + Does not remove key if it is protected. + + :param key: The key to remove. + :type key: str + + :raises ValueError: If key is protected. + + :return: None. + + **Examples:** + + Create a recording and add metadata: + + >>> import numpy + >>> from utils.data import Recording + + >>> samples = numpy.ones(10000, dtype=numpy.complex64) + >>> metadata = { + ... "sample_rate": 1e6, + ... "center_frequency": 2.44e9, + ... } + + >>> recording = Recording(data=samples, metadata=metadata) + >>> print(recording.metadata) + {'sample_rate': 1000000.0, + 'center_frequency': 2440000000.0, + 'timestamp': 17369..., # Example value + 'rec_id': 'fda0f41...'} # Example value + + >>> recording.add_to_metadata(key="author", value="me") + >>> print(recording.metadata) + {'sample_rate': 1000000.0, + 'center_frequency': 2440000000.0, + 'author': 'me', + 'timestamp': 17369..., # Example value + 'rec_id': 'fda0f41...'} # Example value + """ + if key not in PROTECTED_KEYS: + self._metadata.pop(key) + else: + raise ValueError(f"Key {key} is protected and cannot be modified or removed.") + + def view(self, output_path: Optional[str] = "images/signal.png", **kwargs) -> None: + """Create a plot of various signal visualizations as a PNG image. + + :param output_path: The output image path. Defaults to "images/signal.png". + :type output_path: str, optional + :param kwargs: Keyword arguments passed on to utils.view.view_sig. + :type: dict of keyword arguments + + **Examples:** + + Create a recording and view it as a plot in a .png image: + + >>> import numpy + >>> from utils.data import Recording + + >>> samples = numpy.ones(10000, dtype=numpy.complex64) + >>> metadata = { + >>> "sample_rate": 1e6, + >>> "center_frequency": 2.44e9, + >>> } + + >>> recording = Recording(data=samples, metadata=metadata) + >>> recording.view() + """ + from utils.view import view_sig + + view_sig(recording=self, output_path=output_path, **kwargs) + + def simple_view(self, **kwargs) -> None: + """Create a plot of various signal visualizations as a PNG or SVG image. + + :param kwargs: Keyword arguments passed on to utils.view.view_signal_simple.create_plots. + :type: dict of keyword arguments + + **Examples:** + + Create a recording and view it as a plot in a .png image: + + >>> import numpy + >>> from utils.data import Recording + + >>> samples = numpy.ones(10000, dtype=numpy.complex64) + >>> metadata = { + >>> "sample_rate": 1e6, + >>> "center_frequency": 2.44e9, + >>> } + + >>> recording = Recording(data=samples, metadata=metadata) + >>> recording.simple_view() + """ + from utils.view.view_signal_simple import view_simple_sig + + view_simple_sig(recording=self, **kwargs) + + def to_sigmf( + self, filename: Optional[str] = None, path: Optional[os.PathLike | str] = None, overwrite: bool = False + ) -> None: + """Write recording to a set of SigMF files. + + The SigMF io format is defined by the `SigMF Specification Project `_ + + :param recording: The recording to be written to file. + :type recording: utils.data.Recording + :param filename: The name of the file where the recording is to be saved. Defaults to auto generated filename. + :type filename: os.PathLike or str, optional + :param path: The directory path to where the recording is to be saved. Defaults to recordings/. + :type path: os.PathLike or str, optional + + :raises IOError: If there is an issue encountered during the file writing process. + + :return: None + + **Examples:** + + Create a recording and view it as a plot in a `.png` image: + + >>> import numpy + >>> from utils.data import Recording + + >>> samples = numpy.ones(10000, dtype=numpy.complex64) + >>> metadata = { + ... "sample_rate": 1e6, + ... "center_frequency": 2.44e9, + ... } + + >>> recording = Recording(data=samples, metadata=metadata) + >>> recording.view() + """ + from utils.io.recording import to_sigmf + + to_sigmf(filename=filename, path=path, recording=self, overwrite=overwrite) + + def to_npy( + self, filename: Optional[str] = None, path: Optional[os.PathLike | str] = None, overwrite: bool = False + ) -> str: + """Write recording to ``.npy`` binary file. + + :param filename: The name of the file where the recording is to be saved. Defaults to auto generated filename. + :type filename: os.PathLike or str, optional + :param path: The directory path to where the recording is to be saved. Defaults to recordings/. + :type path: os.PathLike or str, optional + + :raises IOError: If there is an issue encountered during the file writing process. + + :return: Path where the file was saved. + :rtype: str + + **Examples:** + + Create a recording and save it to a .npy file: + + >>> import numpy + >>> from utils.data import Recording + + >>> samples = numpy.ones(10000, dtype=numpy.complex64) + >>> metadata = { + >>> "sample_rate": 1e6, + >>> "center_frequency": 2.44e9, + >>> } + + >>> recording = Recording(data=samples, metadata=metadata) + >>> recording.to_npy() + """ + from utils.io.recording import to_npy + + to_npy(recording=self, filename=filename, path=path, overwrite=overwrite) + + def to_wav( + self, + filename: Optional[str] = None, + path: Optional[os.PathLike | str] = None, + target_sample_rate: Optional[int] = 48000, + bits_per_sample: int = 32, + overwrite: bool = False, + ) -> str: + """Write recording to WAV file with embedded YAML metadata. + + WAV format uses stereo audio with I (in-phase) in left channel and Q (quadrature) in right channel. + Metadata is stored in standard LIST INFO chunks with RF-specific metadata encoded as YAML + in the ICMT (comment) field for human readability. + + :param filename: The name of the file where the recording is to be saved. Defaults to auto generated filename. + :type filename: os.PathLike or str, optional + :param path: The directory path to where the recording is to be saved. Defaults to recordings/. + :type path: os.PathLike or str, optional + :param target_sample_rate: Sample rate stored in the WAV header when no sample_rate metadata + is present. IQ samples are written without decimation or interpolation. Default is 48000 Hz. + :type target_sample_rate: int, optional + :param bits_per_sample: Bits per sample (32 for float32, 16 for int16). Default is 32. + :type bits_per_sample: int, optional + :param overwrite: Whether to overwrite existing files. Default is False. + :type overwrite: bool, optional + + :raises IOError: If there is an issue encountered during the file writing process. + + :return: Path where the file was saved. + :rtype: str + + **Examples:** + + Create a recording and save it to a .wav file: + + >>> import numpy + >>> from utils.data import Recording + >>> samples = numpy.exp(1j * 2 * numpy.pi * 0.1 * numpy.arange(10000)) + >>> metadata = {"sample_rate": 1e6, "center_frequency": 915e6} + >>> recording = Recording(data=samples, metadata=metadata) + >>> recording.to_wav() + """ + from utils.io.recording import to_wav + + return to_wav( + recording=self, + filename=filename, + path=path, + target_sample_rate=target_sample_rate, + bits_per_sample=bits_per_sample, + overwrite=overwrite, + ) + + def to_blue( + self, + filename: Optional[str] = None, + path: Optional[os.PathLike | str] = None, + data_format: str = "CI", + overwrite: bool = False, + ) -> str: + """Write recording to MIDAS Blue file format. + + MIDAS Blue is a legacy RF file format with a 512-byte binary header. + Commonly used with X-Midas and other RF/radar signal processing tools. + + :param filename: The name of the file where the recording is to be saved. Defaults to auto generated filename. + :type filename: os.PathLike or str, optional + :param path: The directory path to where the recording is to be saved. Defaults to recordings/. + :type path: os.PathLike or str, optional + :param data_format: Format code (default 'CI' = complex int16). + Common formats: 'CI' (complex int16), 'CF' (complex float32), 'CD' (complex float64). + Integer formats require the IQ samples to already be scaled within [-1, 1). + :type data_format: str, optional + :param overwrite: Whether to overwrite existing files. Default is False. + :type overwrite: bool, optional + + :raises IOError: If there is an issue encountered during the file writing process. + + :return: Path where the file was saved. + :rtype: str + + **Examples:** + + Create a recording and save it to a .blue file: + + >>> import numpy + >>> from utils.data import Recording + >>> samples = numpy.ones(10000, dtype=numpy.complex64) + >>> metadata = {"sample_rate": 1e6, "center_frequency": 2.44e9} + >>> recording = Recording(data=samples, metadata=metadata) + >>> recording.to_blue() + """ + from utils.io.recording import to_blue + + return to_blue(recording=self, filename=filename, path=path, data_format=data_format, overwrite=overwrite) + + def trim(self, num_samples: int, start_sample: Optional[int] = 0) -> Recording: + """Trim Recording samples to a desired length, shifting annotations to maintain alignment. + + :param start_sample: The start index of the desired trimmed recording. Defaults to 0. + :type start_sample: int, optional + :param num_samples: The number of samples that the output trimmed recording will have. + :type num_samples: int + :raises IndexError: If start_sample + num_samples is greater than the length of the recording. + :raises IndexError: If sample_start < 0 or num_samples < 0. + + :return: The trimmed Recording. + :rtype: Recording + + **Examples:** + + Create a recording and trim it: + + >>> import numpy + >>> from utils.data import Recording + + >>> samples = numpy.ones(10000, dtype=numpy.complex64) + >>> metadata = { + ... "sample_rate": 1e6, + ... "center_frequency": 2.44e9, + ... } + + >>> recording = Recording(data=samples, metadata=metadata) + >>> print(len(recording)) + 10000 + + >>> trimmed_recording = recording.trim(start_sample=1000, num_samples=1000) + >>> print(len(trimmed_recording)) + 1000 + """ + + if start_sample < 0: + raise IndexError("start_sample cannot be < 0.") + elif start_sample + num_samples > len(self): + raise IndexError( + f"start_sample {start_sample} + num_samples {num_samples} > recording length {len(self)}." + ) + + end_sample = start_sample + num_samples + + data = self.data[:, start_sample:end_sample] + + new_annotations = copy.deepcopy(self.annotations) + for annotation in new_annotations: + # trim annotation if it goes outside the trim boundaries + if annotation.sample_start < start_sample: + annotation.sample_count = annotation.sample_count - (start_sample - annotation.sample_start) + annotation.sample_start = start_sample + + if annotation.sample_start + annotation.sample_count > end_sample: + annotation.sample_count = end_sample - annotation.sample_start + + # shift annotation to align with the new start point + annotation.sample_start = annotation.sample_start - start_sample + + return Recording(data=data, metadata=self.metadata, annotations=new_annotations) + + def normalize(self) -> Recording: + """Scale the recording data, relative to its maximum value, so that the magnitude of the maximum sample is 1. + + :return: Recording where the maximum sample amplitude is 1. + :rtype: Recording + + **Examples:** + + Create a recording with maximum amplitude 0.5 and normalize to a maximum amplitude of 1: + + >>> import numpy + >>> from utils.data import Recording + + >>> samples = numpy.ones(10000, dtype=numpy.complex64) * 0.5 + >>> metadata = { + ... "sample_rate": 1e6, + ... "center_frequency": 2.44e9, + ... } + + >>> recording = Recording(data=samples, metadata=metadata) + >>> print(numpy.max(numpy.abs(recording.data))) + 0.5 + + >>> normalized_recording = recording.normalize() + >>> print(numpy.max(numpy.abs(normalized_recording.data))) + 1 + """ + scaled_data = self.data / np.max(abs(self.data)) + return Recording(data=scaled_data, metadata=self.metadata, annotations=self.annotations) + + def __len__(self) -> int: + """The length of a recording is defined by the number of complex samples in each channel of the recording.""" + return self.shape[1] + + def __eq__(self, other: Recording) -> bool: + """Two Recordings are equal if all data, metadata, and annotations are the same.""" + + # counter used to allow for differently ordered annotation lists + return ( + np.array_equal(self.data, other.data) + and self.metadata == other.metadata + and self.annotations == other.annotations + ) + + def __ne__(self, other: Recording) -> bool: + """Two Recordings are equal if all data, and metadata, and annotations are the same.""" + return not self.__eq__(other=other) + + def __iter__(self) -> Iterator: + self._index = 0 + return self + + def __next__(self) -> np.ndarray: + if self._index < self.n_chan: + to_ret = self.data[self._index] + self._index += 1 + return to_ret + else: + raise StopIteration + + def __getitem__(self, key: int | tuple[int] | slice) -> np.ndarray | np.complexfloating: + """If key is an integer, tuple of integers, or a slice, return the corresponding samples. + + For arrays with 1,024 or fewer samples, return a copy of the recording data. For larger arrays, return a + read-only view. This prevents mutation at a distance while maintaining performance. + """ + if isinstance(key, (int, tuple, slice)): + v = self._data[key] + if isinstance(v, np.complexfloating): + return v + elif v.size > 1024: + v.setflags(write=False) # Make view read-only. + return v + else: + return v.copy() + + else: + raise ValueError(f"Key must be an integer, tuple, or slice but was {type(key)}.") + + def __setitem__(self, *args, **kwargs) -> None: + """Raise an error if an attempt is made to assign to the recording.""" + raise ValueError("Assignment to Recording is not allowed.") + + +def generate_recording_id(data: np.ndarray, timestamp: Optional[float | int] = None) -> str: + """Generate unique 64-character recording ID. The recording ID is generated by hashing the recording data with + the datetime that the recording data was generated. If no datatime is provided, the current datatime is used. + + :param data: Tape of IQ samples, as a NumPy array. + :type data: np.ndarray + :param timestamp: Unix timestamp in seconds. Defaults to None. + :type timestamp: float or int, optional + + :return: 256-character hash, to be used as the recording ID. + :rtype: str + """ + if timestamp is None: + timestamp = time.time() + + byte_sequence = data.tobytes() + str(timestamp).encode("utf-8") + sha256_hash = hashlib.sha256(byte_sequence) + + return sha256_hash.hexdigest() + + +def _is_jsonable(x: Any) -> bool: + """ + :return: True if x is JSON serializable, False otherwise. + """ + try: + json.dumps(x) + return True + except (TypeError, OverflowError): + return False + + +def _is_valid_metadata_key(key: Any) -> bool: + """ + :return: True if key is a valid metadata key, False otherwise. + """ + if isinstance(key, str) and key.islower() and re.match(pattern=r"^[a-z_]+$", string=key) is not None: + return True + + else: + return False From af3ae03bafb810a031e76de5a7736b0de184d18d Mon Sep 17 00:00:00 2001 From: gillian Date: Mon, 23 Feb 2026 14:09:42 -0500 Subject: [PATCH 05/43] Moving annotate into CLI --- src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py | 2 +- src/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py | 4 ++-- src/ria_toolkit_oss_cli/ria_toolkit_oss/view.py | 7 ++++++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py b/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py index 74e1370..1ae1e32 100644 --- a/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py +++ b/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py @@ -14,7 +14,7 @@ from ria_toolkit_oss.annotations import ( from ria_toolkit_oss.data import Annotation from ria_toolkit_oss.data.recording import Recording from ria_toolkit_oss.io import load_recording, to_blue, to_npy, to_sigmf, to_wav -from ria_toolkit_oss_cli.common import format_frequency, format_sample_count +from ria_toolkit_oss_cli.ria_toolkit_oss.common import format_frequency, format_sample_count def normalize_sigmf_path(filepath): diff --git a/src/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py b/src/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py index 60ddba9..77cac92 100644 --- a/src/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py +++ b/src/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py @@ -2,7 +2,7 @@ """ This module contains all the CLI bindings for the ria package. """ - +from .annotate import annotate from .capture import capture from .combine import combine from .convert import convert @@ -16,7 +16,7 @@ from .init import init from .split import split from .transform import transform from .transmit import transmit -from .view import view +from .view import viewe # Aliases synth = generate diff --git a/src/ria_toolkit_oss_cli/ria_toolkit_oss/view.py b/src/ria_toolkit_oss_cli/ria_toolkit_oss/view.py index 8e0b51f..9c67e80 100644 --- a/src/ria_toolkit_oss_cli/ria_toolkit_oss/view.py +++ b/src/ria_toolkit_oss_cli/ria_toolkit_oss/view.py @@ -7,7 +7,7 @@ from typing import Optional import click from ria_toolkit_oss.io.recording import from_npy, load_recording -from ria_toolkit_oss.view.view_signal import view_channels, view_sig +from ria_toolkit_oss.view.view_signal import view_annotations, view_channels, view_sig from ria_toolkit_oss.view.view_signal_simple import view_simple_sig from .common import echo_progress, echo_verbose, load_yaml_config @@ -33,6 +33,11 @@ VISUALIZATION_TYPES = { "dark", "spines", ], + }, + "annotations": { + "function": view_annotations, + "description": "Annotation-focused spectrogram view", + "options": ["channel", "dark"], }, "channels": {"function": view_channels, "description": "Multi-channel IQ and spectrogram view", "options": []}, } From 16ac8dbfb6034701c34017f27c42a5b2691bfde4 Mon Sep 17 00:00:00 2001 From: muq Date: Mon, 23 Feb 2026 14:12:34 -0500 Subject: [PATCH 06/43] updated annotations from utils to oss --- .../annotations/annotation_transforms.py | 55 +++ .../annotations/cusum_annotator.py | 203 ++++++++ .../annotations/energy_detector.py | 438 ++++++++++++++++++ .../annotations/parallel_signal_separator.py | 435 +++++++++++++++++ .../annotations/qualify_slice.py | 35 ++ .../annotations/signal_isolation.py | 97 ++++ .../annotations/threshold_qualifier.py | 212 +++++++++ src/ria_toolkit_oss/view/view_signal.py | 95 +++- .../view/view_signal_simple.py | 68 ++- 9 files changed, 1618 insertions(+), 20 deletions(-) create mode 100644 src/ria_toolkit_oss/annotations/annotation_transforms.py create mode 100644 src/ria_toolkit_oss/annotations/cusum_annotator.py create mode 100644 src/ria_toolkit_oss/annotations/energy_detector.py create mode 100644 src/ria_toolkit_oss/annotations/parallel_signal_separator.py create mode 100644 src/ria_toolkit_oss/annotations/qualify_slice.py create mode 100644 src/ria_toolkit_oss/annotations/signal_isolation.py create mode 100644 src/ria_toolkit_oss/annotations/threshold_qualifier.py diff --git a/src/ria_toolkit_oss/annotations/annotation_transforms.py b/src/ria_toolkit_oss/annotations/annotation_transforms.py new file mode 100644 index 0000000..af48465 --- /dev/null +++ b/src/ria_toolkit_oss/annotations/annotation_transforms.py @@ -0,0 +1,55 @@ +from utils.data.annotation import Annotation + +# TODO figure out how to transfer labels in the merge case + + +def remove_contained_boxes(annotations: list[Annotation]): + """ + Remove all annotations (bounding boxes) that are entirely contained within other boxes in the list. + + :param annotations: A list of Annotation objects. + :type annotations: list[Annotation] + + :returns: A new list of Annotation objects. + :rtype: list[Annotation]""" + + output_boxes = [] + + for i in range(len(annotations)): + contained = False + for j in range(len(annotations)): + if i != j and is_annotation_contained(annotations[i], annotations[j]): + contained = True + break + + if not contained: + output_boxes.append(annotations[i]) + + return output_boxes + + +def is_annotation_contained(inner: Annotation, outer: Annotation) -> bool: + """ + Check if an annotation box is entirely contained within another annotation bounding box. + + :param inner: The inner box. + :type inner: Annotation. + :param outer: The outer box. + :type outer: Annotation. + + :returns: True if inner is within outer, false otherwise. + :rtype: bool + """ + + inner_sample_stop = inner.sample_start + inner.sample_count + outer_sample_stop = outer.sample_start + outer.sample_count + + if inner.sample_start > outer.sample_start and inner_sample_stop < outer_sample_stop: + if inner.freq_lower_edge > outer.freq_lower_edge and inner.freq_upper_edge < outer.freq_upper_edge: + return True + + return False + + +def merge_annotations(annotations: list[Annotation], overlap_threshold) -> list[Annotation]: + raise NotImplementedError diff --git a/src/ria_toolkit_oss/annotations/cusum_annotator.py b/src/ria_toolkit_oss/annotations/cusum_annotator.py new file mode 100644 index 0000000..a32162b --- /dev/null +++ b/src/ria_toolkit_oss/annotations/cusum_annotator.py @@ -0,0 +1,203 @@ +import json +from typing import Optional + +import numpy as np + +from utils.data import Annotation, Recording + + +def annotate_with_cusum( + recording: Recording, + label: Optional[str] = "segment", + window_size: Optional[int] = 1, + min_duration: Optional[float] = None, + tolerance: Optional[int] = None, + annotation_type: Optional[str] = "standalone", +): + """ + Add annotations that divide the recording into distinct time segments. + + This algorithm computes the cumulative sum of the sample magnitudes and + determines break points in the signal. + + This tool can be used to find points where a signal turns on or off, or + changes between a low and high amplitude. + + :param recording: A ``Recording`` object to annotate. + :type recording: ``utils.data.Recording`` + :param label: Label for the detected segments. + :type label: str + :param window_size: The length (in samples) of the moving average window. + :type window_size: int + :param min_duration: The minimum duration (in ms) of a segment. + The algorithm will not produce annotations shorter than this length. + :type min_duration: float + :param tolerance: The minimum length (in samples) of a segment. + :type tolerance: int + :param annotation_type: Annotation type (standalone, parallel, intersection). + :type annotation_type: str + """ + + sample_rate = recording.metadata["sample_rate"] + center_frequency = recording.metadata.get("center_frequency", 0) + + # Create an object of the time segmenter + time_segmenter = TimeSegmenter(sample_rate, min_duration, window_size, tolerance) + + change_points = time_segmenter.apply(recording.data[0]) + + time_segments_indices = np.append(np.insert(change_points, 0, 0), len(recording.data[0])) + annotations = [] + for i in range(len(time_segments_indices) - 1): + # Build comment JSON with type metadata + comment_data = { + "type": annotation_type, + "generator": "cusum_annotator", + "params": { + "window_size": window_size, + "min_duration": min_duration, + "tolerance": tolerance, + }, + } + f_min, f_max = detect_frequency( + signal=recording.data[0], + start=time_segments_indices[i], + stop=time_segments_indices[i + 1], + sample_rate=sample_rate, + ) + + annotations.append( + Annotation( + sample_start=time_segments_indices[i], + sample_count=time_segments_indices[i + 1] - time_segments_indices[i], + freq_lower_edge=center_frequency + f_min, + freq_upper_edge=center_frequency + f_max, + label=label, + comment=json.dumps(comment_data), + detail={"generator": "cusum_annotator"}, + ) + ) + + return Recording(data=recording.data, metadata=recording.metadata, annotations=recording.annotations + annotations) + + +def _compute_cusum(_signal, sample_rate: int, tolerance: int = None, min_duration: float = -1): + """ + This function efficiently computes the cumulative sum of a give list (_signal), with an optional tolerance. + + Args: + - _signal: array of iq samples. + - Tolerance: the least acceptable length of a block, Defaults to None. + + Returns: + - cusum (array): Array of the cumulative sum of the given list + - sample_rate (int): __description_ + - change_points (array): Array of the indices at which a change in the CUSUM direction happens. + - min_duration (float): The least acceptable time width of each segment (in ms). Defaults to -1. + """ + + # efficiently calculate the running sum of the signal + # cusum = list(itertools.accumulate((_signal - np.mean(_signal)))) + x = _signal - np.mean(_signal) + cusum = np.cumsum(x) + + # 'diff' computes the differences between the consecutive values, + # then 'sign' determines if it is +ve or -ve. + change_indicators = np.sign(np.diff(cusum)) + change_points = np.where(np.diff(change_indicators))[0] + 1 + + # Limit the change_points + # Reject those whose number of samples < minimum accepted #n of samples in (min duration) ms. + if min_duration is not None and min_duration > 0: + min_samples_wide = int(min_duration * sample_rate / 1000) + segments_lengths = np.diff(change_points) + segments_lengths = np.insert(segments_lengths, 0, change_points[0]) + change_points = change_points[np.where(segments_lengths > min_samples_wide)[0]] + return cusum, change_points + + +def detect_frequency(signal, start, stop, sample_rate): + signal_segment = signal[start:stop] + if len(signal_segment) > 0: + fft_data = np.abs(np.fft.fftshift(np.fft.fft(signal_segment))) + fft_freqs = np.fft.fftshift(np.fft.fftfreq(len(signal_segment), 1 / sample_rate)) + + # Use a spectral threshold to find the 'height' of the orange block + spectral_thresh = np.max(fft_data) * 0.15 + sig_indices = np.where(fft_data > spectral_thresh)[0] + + if len(sig_indices) > 4: + return fft_freqs[sig_indices[0]], fft_freqs[sig_indices[-1]] + else: + return -sample_rate / 4, sample_rate / 4 + else: + return -sample_rate / 4, sample_rate / 4 + + +class TimeSegmenter: + """Time Segmenter class, it creates a segmenter object with certain\ + characteristics to easily split an input signal to segments based on\ + the cumulative sum of deviations (of the signal mean) + """ + + def __init__( + self, sample_rate: int, min_duration: float = 1, moving_average_window: int = 3, tolerance: int = None + ): + """_summary_ + + Args: + sample_rate (int): _description_ + min_duration (float, optional): _description_. Defaults to 1. + moving_average_window (int, optional): _description_. Defaults to 3. + tolerance (int, optional): _description_. Defaults to None. + """ + self.sample_rate = sample_rate + self.min_duration = min_duration + self.moving_average_window = moving_average_window + self._moving_avg_filter = self._init_filter() + self.tolerance = tolerance + + def _init_filter(self): + """_summary_ + + Returns: + _type_: _description_ + """ + return np.ones(self.moving_average_window) / self.moving_average_window + + def _apply_filter(self, iqsignal: np.array): + """_summary_ + + Args: + iqsignal (np.array): _description_ + + Returns: + _type_: _description_ + """ + return np.convolve(abs(iqsignal), self._moving_avg_filter, mode="same") + + def _create_segments(self, iq_signal: np.array, change_points: np.array): + """_summary_ + + Args: + iq_signal (np.array): _description_ + change_points (np.array): _description_ + + Returns: + _type_: _description_ + """ + return np.split(iq_signal, change_points) + + def apply(self, iq_signal: np.array): + """_summary_ + + Args: + iq_signal (np.array): _description_ + + Returns: + _type_: _description_ + """ + smoothed_signal = self._apply_filter(iq_signal) + _, change_points = _compute_cusum(smoothed_signal, self.sample_rate, self.tolerance, self.min_duration) + # segments = self._create_segments(iq_signal, change_points) + return change_points diff --git a/src/ria_toolkit_oss/annotations/energy_detector.py b/src/ria_toolkit_oss/annotations/energy_detector.py new file mode 100644 index 0000000..6cc2466 --- /dev/null +++ b/src/ria_toolkit_oss/annotations/energy_detector.py @@ -0,0 +1,438 @@ +""" +Energy-based signal detection and bandwidth analysis. + +Provides automatic annotation generation using energy-based signal detection +and occupied bandwidth calculation following ITU-R SM.328 standard. +""" + +import json +from typing import Tuple + +import numpy as np +from scipy.signal import filtfilt + +from utils.data import Annotation, Recording + + +def detect_signals_energy( + recording: Recording, + k: int = 10, + threshold_factor: float = 1.2, + window_size: int = 200, + min_distance: int = 5000, + label: str = "signal", + annotation_type: str = "standalone", + freq_method: str = "nbw", + nfft: int = None, + obw_power: float = 0.99, +) -> Recording: + """ + Detect signal bursts using energy-based method with adaptive noise floor estimation. + + This algorithm smooths the signal with a moving average filter, estimates the noise + floor from k segments, applies a threshold to detect regions above noise, and merges + nearby detections. Detected time boundaries are then assigned frequency bounds based + on the selected frequency method. + + Time Detection Algorithm: + 1. Smooth signal using moving average (envelope detection) + 2. Divide smoothed signal into k segments + 3. Estimate noise floor as median of segment mean powers + 4. Detect regions where power exceeds threshold_factor * noise_floor + 5. Merge regions closer than min_distance samples + + Frequency Bounding (freq_method): + - 'nbw': Nominal bandwidth (OBW + center frequency) - DEFAULT + - 'obw': Occupied bandwidth (99.99% power, includes siedelobes) + - 'full-detected': Lowest to highest spectral component + - 'full-bandwidth': Entire Nyquist span (center_freq ± sample_rate/2) + + :param recording: Recording to analyze + :type recording: Recording + :param k: Number of segments for noise floor estimation (default: 10) + :type k: int + :param threshold_factor: Threshold multiplier above noise floor (typical: 1.2-2.0, default: 1.2) + :type threshold_factor: float + :param window_size: Moving average window size in samples (default: 200) + :type window_size: int + :param min_distance: Minimum distance between separate signals in samples (default: 5000) + :type min_distance: int + :param label: Label for detected annotations (default: "signal") + :type label: str + :param annotation_type: Annotation type (standalone, parallel, intersection, default: standalone) + :type annotation_type: str + :param freq_method: How to calculate frequency bounds (default: 'nbw') + :type freq_method: str + :param nfft: FFT size for frequency calculations (default: None) + :type nfft: int + :param obw_power: Power percentage for OBW (0.9999 = 99.99%, default: 0.99) + :type obw_power: float + + :returns: New Recording with added annotations + :rtype: Recording + + **Example**:: + + >>> from utils.io import load_recording + >>> from utils.annotations import detect_signals_energy + >>> recording = load_recording("capture.sigmf") + + >>> # Detect with NBW frequency bounds (default, best for real signals) + >>> annotated = detect_signals_energy(recording, label="burst") + + >>> # Detect with OBW (more conservative, includes siedelobes) + >>> annotated = detect_signals_energy( + ... recording, label="burst", freq_method="obw" + ... ) + + >>> # Detect with full detected range (captures all spectral components) + >>> annotated = detect_signals_energy( + ... recording, label="burst", freq_method="full-detected" + ... ) + """ + # Extract signal data (use first channel only) + signal = recording.data[0] + + # Calculate smoothed signal power + kernel = np.ones(window_size) / window_size + smoothed_power = filtfilt(kernel, [1], np.abs(signal) ** 2) + + # Estimate noise floor using segment-based median (robust to signal presence) + segments = np.array_split(smoothed_power, k) + noise_floor = np.median([np.mean(s) for s in segments]) + + # Detect signal boundaries (regions above threshold) + enter = noise_floor * threshold_factor + exit = enter * 0.8 + boundaries = [] + start = None + active = False + + for i, p in enumerate(smoothed_power): + if not active and p > enter: + start = i + active = True + elif active and p < exit: + boundaries.append((start, i - start)) + active = False + + if active: + boundaries.append((start, len(smoothed_power) - start)) + + # Merge boundaries that are closer than min_distance + merged_boundaries = [] + if boundaries: + start, length = boundaries[0] + for next_start, next_length in boundaries[1:]: + if next_start - (start + length) < min_distance: + # Merge with current boundary + length = next_start + next_length - start + else: + # Save current and start new boundary + merged_boundaries.append((start, length)) + start, length = next_start, next_length + # Add final boundary + merged_boundaries.append((start, length)) + + # Create annotations from detected boundaries + sample_rate = recording.metadata["sample_rate"] + center_frequency = recording.metadata.get("center_frequency", 0) + + # Validate frequency method + valid_freq_methods = ["nbw", "obw", "full-detected", "full-bandwidth"] + if freq_method not in valid_freq_methods: + raise ValueError(f"Invalid freq_method '{freq_method}'. " f"Must be one of: {', '.join(valid_freq_methods)}") + + annotations = [] + for start_sample, sample_count in merged_boundaries: + # Calculate frequency bounds based on method + freq_lower, freq_upper = calculate_frequency_bounds( + freq_method, center_frequency, sample_rate, nfft, signal, start_sample, sample_count, obw_power + ) + # Build comment JSON with type metadata + comment_data = { + "type": annotation_type, + "generator": "energy_detector", + "freq_method": freq_method, + "params": { + "threshold_factor": threshold_factor, + "window_size": window_size, + "noise_floor": float(noise_floor), + "threshold": float(enter), + }, + } + + anno = Annotation( + sample_start=start_sample, + sample_count=sample_count, + freq_lower_edge=freq_lower, + freq_upper_edge=freq_upper, + label=label, + comment=json.dumps(comment_data), + detail={"generator": "energy_detector", "freq_method": freq_method}, + ) + annotations.append(anno) + + return Recording(data=recording.data, metadata=recording.metadata, annotations=recording.annotations + annotations) + + +def calculate_occupied_bandwidth( + signal: np.ndarray, + sampling_rate: float, + nfft: int = None, + power_percentage: float = 0.99, +): + if nfft is None: + nfft = max(65536, 2 ** int(np.floor(np.log2(len(signal))))) + + window = np.blackman(len(signal)) + spec = np.fft.fftshift(np.fft.fft(signal * window, n=nfft)) + + psd = np.abs(spec) ** 2 + psd = psd / psd.sum() # normalize + + freqs = np.fft.fftshift(np.fft.fftfreq(nfft, 1 / sampling_rate)) + + cdf = np.cumsum(psd) + + tail = (1 - power_percentage) / 2 + + lower_idx = np.searchsorted(cdf, tail) + upper_idx = np.searchsorted(cdf, 1 - tail) + + return freqs[upper_idx] - freqs[lower_idx], freqs[lower_idx], freqs[upper_idx] + + +def calculate_nominal_bandwidth( + signal: np.ndarray, + sampling_rate: float, + nfft: int = None, + power_percentage: float = 0.99, +) -> Tuple[float, float]: + """ + Calculate nominal bandwidth and center frequency. + + Nominal bandwidth (NBW) is the occupied bandwidth along with the center + frequency of the signal's spectral occupancy. Useful for characterizing + signals with unknown or drifting center frequencies. + + :param signal: Complex IQ signal samples + :type signal: np.ndarray + :param sampling_rate: Sample rate in Hz + :type sampling_rate: float + :param nfft: FFT size + :type nfft: int + :param power_percentage: Fraction of power to contain + :type power_percentage: float + + :returns: Tuple of (nominal_bandwidth_hz, center_frequency_hz) + :rtype: Tuple[float, float] + + **Example**:: + + >>> from utils.annotations import calculate_nominal_bandwidth + >>> nbw, center = calculate_nominal_bandwidth(signal, sampling_rate=10e6) + >>> print(f"NBW: {nbw/1e6:.3f} MHz, Center: {center/1e6:.3f} MHz") + """ + bw, lower_freq, upper_freq = calculate_occupied_bandwidth(signal, sampling_rate, nfft, power_percentage) + + # Center frequency is midpoint of occupied band + center_freq = (lower_freq + upper_freq) / 2 + + return lower_freq, upper_freq, center_freq + + +def calculate_full_detected_bandwidth( + signal: np.ndarray, + sampling_rate: float, + nfft: int = None, + start_offset: int = 1000, +) -> Tuple[float, float, float]: + """ + Calculate frequency range from lowest to highest spectral component. + + Unlike OBW/NBW which define a power-based bandwidth, this calculates + the absolute frequency span from the lowest non-zero spectral component + to the highest non-zero component. + + Useful for: + - Signals with spectral gaps + - Multiple parallel signals (captures all of them) + - Understanding total occupied spectrum vs. actual bandwidth + + :param signal: Complex IQ signal samples + :type signal: np.ndarray + :param sampling_rate: Sample rate in Hz + :type sampling_rate: float + :param nfft: FFT size + :type nfft: int + :param start_offset: Skip samples at start + :type start_offset: int + + :returns: Tuple of (bandwidth_hz, lower_freq_hz, upper_freq_hz) + :rtype: Tuple[float, float, float] + + **Example**:: + + >>> # Signal with two components at different frequencies + >>> bw, f_low, f_high = calculate_full_detected_bandwidth( + ... signal, sampling_rate=10e6, nfft=65536 + ... ) + >>> print(f"Full span: {f_low/1e6:.3f} to {f_high/1e6:.3f} MHz") + """ + # Validate input + if len(signal) < nfft + start_offset: + raise ValueError( + f"Signal too short: need {nfft + start_offset} samples, " + f"got {len(signal)}. Reduce nfft or start_offset." + ) + + # Extract segment + signal_segment = signal[start_offset : nfft + start_offset] + + # Compute FFT and power spectral density + freq_spectrum = np.fft.fft(signal_segment, n=nfft) + psd = np.abs(freq_spectrum) ** 2 + + # Shift to center DC + psd_shifted = np.fft.fftshift(psd) + freq_bins = np.fft.fftshift(np.fft.fftfreq(nfft, 1 / sampling_rate)) + + # Find noise floor (mean of lowest 10% of bins) and all bins above noise floor + noise_floor = np.mean(np.sort(psd_shifted)[: int(len(psd_shifted) * 0.1)]) + above_noise = np.where(psd_shifted > noise_floor * 1.5)[0] + + if len(above_noise) == 0: + # No signal above noise, return zero bandwidth + return 0.0, 0.0, 0.0 + + # Get frequency range of signal components + lower_idx = above_noise[0] + upper_idx = above_noise[-1] + + lower_freq = freq_bins[lower_idx] + upper_freq = freq_bins[upper_idx] + + bandwidth = upper_freq - lower_freq + + return bandwidth, lower_freq, upper_freq + + +def annotate_with_obw( + recording: Recording, + label: str = "signal", + annotation_type: str = "standalone", + nfft: int = None, + power_percentage: float = 0.99, +) -> Recording: + """ + Create a single annotation spanning the occupied bandwidth of the entire recording. + + Analyzes the full recording to find its occupied bandwidth and creates an annotation + covering that frequency range for the entire time duration. + + :param recording: Recording to analyze + :type recording: Recording + :param label: Annotation label + :type label: str + :param annotation_type: Annotation type + :type annotation_type: str + :param nfft: FFT size + :type nfft: int + :param power_percentage: Power percentage for OBW calculation + :type power_percentage: float + + :returns: Recording with OBW annotation added + :rtype: Recording + + **Example**:: + + >>> from utils.annotations import annotate_with_obw + >>> annotated = annotate_with_obw(recording, label="signal_obw") + """ + signal = recording.data[0] + sample_rate = recording.metadata["sample_rate"] + center_freq = recording.metadata.get("center_frequency", 0) + + # Calculate OBW + obw, lower_offset, upper_offset = calculate_occupied_bandwidth(signal, sample_rate, nfft, power_percentage) + + # Convert baseband offsets to absolute frequencies + freq_lower = center_freq + lower_offset + freq_upper = center_freq + upper_offset + + # Create comment JSON + comment_data = { + "type": annotation_type, + "generator": "obw_annotator", + "obw_hz": float(obw), + "power_percentage": power_percentage, + "params": {"nfft": nfft}, + } + + # Create annotation spanning entire recording + anno = Annotation( + sample_start=0, + sample_count=len(signal), + freq_lower_edge=freq_lower, + freq_upper_edge=freq_upper, + label=label, + comment=json.dumps(comment_data), + detail={"generator": "obw_annotator", "obw_hz": float(obw)}, + ) + + return Recording(data=recording.data, metadata=recording.metadata, annotations=recording.annotations + [anno]) + + +def calculate_frequency_bounds( + freq_method, center_frequency, sample_rate, nfft, signal, start_sample, sample_count, obw_power +): + if freq_method == "full-bandwidth": + # Full Nyquist span + freq_lower = center_frequency - (sample_rate / 2) + freq_upper = center_frequency + (sample_rate / 2) + else: + # Extract segment for frequency analysis + segment_start = start_sample + segment_end = min(start_sample + sample_count, len(signal)) + segment = signal[segment_start:segment_end] + + if nfft is None or len(segment) >= nfft: + if freq_method == "nbw": + # Nominal bandwidth (OBW + center frequency) + try: + lower_freq, upper_freq, _ = calculate_nominal_bandwidth(segment, sample_rate, nfft, obw_power) + freq_lower = center_frequency + lower_freq + freq_upper = center_frequency + upper_freq + except (ValueError, IndexError): + # Fallback if calculation fails + freq_lower = center_frequency - (sample_rate / 2) + freq_upper = center_frequency + (sample_rate / 2) + + elif freq_method == "obw": + # Occupied bandwidth + try: + _, f_lower, f_upper = calculate_occupied_bandwidth(segment, sample_rate, nfft, obw_power) + freq_lower = center_frequency + f_lower + freq_upper = center_frequency + f_upper + except (ValueError, IndexError): + # Fallback if calculation fails + freq_lower = center_frequency - (sample_rate / 2) + freq_upper = center_frequency + (sample_rate / 2) + + elif freq_method == "full-detected": + # Full detected range (lowest to highest component) + try: + _, f_lower, f_upper = calculate_full_detected_bandwidth(segment, sample_rate, nfft) + freq_lower = center_frequency + f_lower + freq_upper = center_frequency + f_upper + except (ValueError, IndexError): + # Fallback if calculation fails + freq_lower = center_frequency - (sample_rate / 2) + freq_upper = center_frequency + (sample_rate / 2) + else: + # Segment too short for FFT, use full bandwidth + freq_lower = center_frequency - (sample_rate / 2) + freq_upper = center_frequency + (sample_rate / 2) + + return freq_lower, freq_upper diff --git a/src/ria_toolkit_oss/annotations/parallel_signal_separator.py b/src/ria_toolkit_oss/annotations/parallel_signal_separator.py new file mode 100644 index 0000000..b75a28f --- /dev/null +++ b/src/ria_toolkit_oss/annotations/parallel_signal_separator.py @@ -0,0 +1,435 @@ +""" +Parallel signal separation for multi-component frequency-offset signals. + +Provides methods to detect and separate overlapping frequency-domain signals +that occupy the same time window but different frequency bands. + +This module implements **spectral peak detection** to identify distinct frequency +components and split single time-domain annotations into frequency-specific +sub-annotations. + +**Key Design Decisions** (per Codex review): + +1. **Complex IQ Support**: Uses `scipy.signal.welch` with `return_onesided=False` + for proper complex signal handling. Window length automatically adapts to + signal length via `nperseg=min(nfft, len(signal))` to handle bursts >> from utils.annotations import find_spectral_components + >>> # Detect the two distinct channels (returns relative frequencies) + >>> components = find_spectral_components(signal, sampling_rate=20e6) + >>> print(f"Found {len(components)} components") + Found 2 components + +The module is designed to work with detected time-domain annotations, +allowing splitting of overlapping signals into separate training samples. +""" + +import json +from typing import List, Optional, Tuple + +import numpy as np +from scipy import ndimage +from scipy import signal as scipy_signal + +from utils.data import Annotation, Recording + + +def find_spectral_components( + signal_data: np.ndarray, + sampling_rate: float, + nfft: int = 65536, + noise_threshold_db: Optional[float] = None, + min_component_bw: float = 50e3, + time_percentile: float = 70.0, +) -> List[Tuple[float, float, float]]: + """ + Find distinct frequency components using spectral peak detection. + + Identifies separate frequency components in a signal by analyzing the power + spectral density and finding peaks corresponding to distinct signals. This is + useful for separating parallel signals that occupy different frequency bands. + + **Frequency Representation**: Returns frequencies in **baseband/relative** Hz + (centered at 0). To get absolute RF frequencies, add center_frequency_hz from + recording metadata to all returned values. + + Algorithm: + 1. Compute power spectral density using Welch (properly handles complex IQ) + 2. Auto-estimate noise floor from data if not specified + 3. Smooth PSD to reduce spurious peaks + 4. Find local maxima above noise floor + 5. Estimate bandwidth per peak using -3dB (fallback: cumulative power) + 6. Filter components below minimum bandwidth threshold + + :param signal_data: Complex IQ signal samples (np.complex64/128) + :type signal_data: np.ndarray + :param sampling_rate: Sample rate in Hz + :type sampling_rate: float + :param nfft: FFT size / window length for Welch. Automatically capped at + signal length to handle bursts (default: 65536) + :type nfft: int + :param noise_threshold_db: Minimum SNR threshold in dB. If None (default), + auto-estimates as np.percentile(psd_db, 10). + Adapt this across hardware (Pluto: ~-100, ThinkRF: ~-60). + :type noise_threshold_db: Optional[float] + :param min_component_bw: Minimum component bandwidth in Hz (default: 50 kHz) + :type min_component_bw: float + :param power_threshold: Cumulative power threshold for fallback bandwidth + estimation (default: 0.99 = 99% power, like OBW) + :type power_threshold: float + + :returns: List of (center_freq_hz, lower_freq_hz, upper_freq_hz) tuples. + **All frequencies are relative (baseband, 0-centered).** + Add recording metadata['center_frequency'] to get absolute RF frequencies. + :rtype: List[Tuple[float, float, float]] + + :raises ValueError: If signal has fewer than 256 samples + + **Example**:: + + >>> from utils.io import load_recording + >>> from utils.annotations import find_spectral_components + >>> recording = load_recording("capture.sigmf") + >>> segment = recording.data[0][start:end] + >>> # Components in relative (baseband) frequency + >>> components = find_spectral_components(segment, sampling_rate=20e6) + >>> for center_rel, lower_rel, upper_rel in components: + ... # Convert to absolute RF frequency + ... center_abs = recording.metadata['center_frequency'] + center_rel + ... print(f"Component @ {center_abs/1e9:.3f} GHz") + """ + # Validate input + min_samples = 256 + if len(signal_data) < min_samples: + raise ValueError(f"Signal too short: need at least {min_samples} samples, " f"got {len(signal_data)}.") + + # Compute PSD using Welch method for complex IQ signals + # CRITICAL: return_onesided=False for proper complex signal handling + nperseg = min(nfft, len(signal_data)) + noverlap = nperseg // 2 + + # --- STFT --- + freqs, times, Zxx = scipy_signal.stft( + signal_data, + fs=sampling_rate, + window="blackman", + nperseg=nperseg, + noverlap=noverlap, + return_onesided=False, + boundary=None, + ) + + # Shift zero freq to center + Zxx = np.fft.fftshift(Zxx, axes=0) + freqs = np.fft.fftshift(freqs) + + # Power spectrogram + power = np.abs(Zxx) ** 2 + power_db = 10 * np.log10(power + 1e-12) + + # --- Aggregate across time robustly --- + # Using percentile instead of mean prevents short signals from being diluted + freq_profile_db = np.percentile(power_db, time_percentile, axis=1) + + # --- Noise floor estimation --- + if noise_threshold_db is None: + noise_threshold_db = np.percentile(freq_profile_db, 20) + + threshold = noise_threshold_db + 3 # 3 dB above noise floor + + # --- Smooth lightly (avoid merging nearby signals) --- + freq_profile_db = ndimage.gaussian_filter1d(freq_profile_db, sigma=1.5) + + # --- Binary mask of significant frequencies --- + mask = freq_profile_db > threshold + + # --- Find contiguous frequency regions --- + labeled, num_features = ndimage.label(mask) + + components = [] + + for region_label in range(1, num_features + 1): + region_indices = np.where(labeled == region_label)[0] + + if len(region_indices) == 0: + continue + + lower_idx = region_indices[0] + upper_idx = region_indices[-1] + + lower_freq = freqs[lower_idx] + upper_freq = freqs[upper_idx] + bw = upper_freq - lower_freq + + if bw < min_component_bw: + continue + + center_freq = (lower_freq + upper_freq) / 2 + components.append((center_freq, lower_freq, upper_freq)) + + return components + + +def split_annotation_by_components( + annotation: Annotation, + signal: np.ndarray, + sampling_rate: float, + center_frequency_hz: float = 0.0, + nfft: int = 65536, + noise_threshold_db: Optional[float] = None, + min_component_bw: float = 50e3, +) -> List[Annotation]: + """ + Split an annotation into multiple annotations by detected frequency components. + + Takes an existing annotation spanning multiple frequency components and + analyzes the frequency content to create separate sub-annotations for + each distinct frequency component. + + **Use case**: Energy detection found a time window with 2-3 parallel WiFi + channels. This function splits it into separate annotations per channel. + + **Frequency Handling**: `find_spectral_components` returns relative (baseband) + frequencies. This function adds `center_frequency_hz` to convert to absolute + RF frequencies for SigMF annotation bounds. This ensures correct frequency + context across baseband and RF domains. + + :param annotation: Original annotation to split + :type annotation: Annotation + :param signal: Full signal array (complex IQ) + :type signal: np.ndarray + :param sampling_rate: Sample rate in Hz + :type sampling_rate: float + :param center_frequency_hz: RF center frequency to add to relative frequencies + from peak detection (default: 0.0 = baseband) + :type center_frequency_hz: float + :param nfft: FFT size for analysis (default: 65536, auto-capped at signal length) + :type nfft: int + :param noise_threshold_db: Noise floor threshold in dB. If None (default), + auto-estimates from data. + :type noise_threshold_db: Optional[float] + :param min_component_bw: Minimum component bandwidth in Hz (default: 50 kHz) + :type min_component_bw: float + + :returns: List of new annotations (one per detected component). + Returns empty list if no components found or segment too short. + :rtype: List[Annotation] + + **Example**:: + + >>> from utils.io import load_recording + >>> from utils.annotations import split_annotation_by_components + >>> recording = load_recording("capture.sigmf") + >>> # Original annotation spans multiple channels + >>> original = recording.annotations[0] + >>> # Split using RF center frequency from metadata + >>> components = split_annotation_by_components( + ... original, + ... recording.data[0], + ... recording.metadata['sample_rate'], + ... center_frequency_hz=recording.metadata.get('center_frequency', 0.0) + ... ) + >>> print(f"Split into {len(components)} components") + Split into 2 components + + **Algorithm**: + 1. Extract segment corresponding to annotation time bounds + 2. Find frequency components in that segment (returns relative frequencies) + 3. Add center_frequency_hz to get absolute RF frequencies + 4. Create new annotation for each component + 5. Preserve original metadata (label, type, etc.) + 6. Add component info to comment JSON + + **Notes**: + - Original annotation is not modified + - Returns empty list if segment too short (<256 samples) + - Segments Recording: + """ + Split multiple annotations in a recording by frequency components. + + Processes specified annotations (or all if indices=None), replacing each + with its frequency-separated components. Uses RF center_frequency from + recording metadata for proper absolute frequency conversion. + + :param recording: Recording to process + :type recording: Recording + :param indices: Annotation indices to split (None = all, default: None). + Use indices=[] to skip splitting (returns unchanged recording). + :type indices: Optional[List[int]] + :param nfft: FFT size for spectral analysis (default: 65536, + auto-capped at signal segment length) + :type nfft: int + :param noise_threshold_db: Noise floor threshold in dB. If None (default), + auto-estimates from each segment. + :type noise_threshold_db: Optional[float] + :param min_component_bw: Minimum component bandwidth in Hz (default: 50 kHz). + Components narrower than this are filtered out. + :type min_component_bw: float + + :returns: New Recording with split annotations + :rtype: Recording + + **Example**:: + + >>> from utils.io import load_recording + >>> from utils.annotations import split_recording_annotations + >>> recording = load_recording("capture.sigmf") + >>> # Split all annotations + >>> split_rec = split_recording_annotations(recording) + >>> print(f"Original: {len(recording.annotations)} annotations") + >>> print(f"Split: {len(split_rec.annotations)} annotations") + Original: 5 annotations + Split: 9 annotations + + **Algorithm**: + 1. For each annotation in indices (or all if None): + 2. Call split_annotation_by_components with RF center_frequency + 3. If components found, replace annotation with components + 4. If no components found, keep original annotation + 5. Annotations not in indices are kept unchanged + + **Notes**: + - Original recording is not modified + - Returns empty Recording.annotations if recording has no annotations + - RF center_frequency from metadata ensures correct absolute frequencies + - If an annotation can't be split (too short, wrong format), original kept + """ + if indices is None: + # Split all annotations + indices = list(range(len(recording.annotations))) + + if not recording.annotations: + # No annotations to split + return recording + + signal = recording.data[0] + sample_rate = recording.metadata["sample_rate"] + center_frequency = recording.metadata.get("center_frequency", 0.0) + + # Build new annotation list + new_annotations = [] + for i, anno in enumerate(recording.annotations): + if i in indices: + # Attempt to split this annotation + try: + components = split_annotation_by_components( + anno, + signal, + sample_rate, + center_frequency_hz=center_frequency, + nfft=nfft, + noise_threshold_db=noise_threshold_db, + min_component_bw=min_component_bw, + ) + if components: + # Split successful, use components + new_annotations.extend(components) + else: + # No components found, keep original + new_annotations.append(anno) + except Exception: + # Split failed for any reason, keep original + new_annotations.append(anno) + else: + # Not in split list, keep as-is + new_annotations.append(anno) + + return Recording(data=recording.data, metadata=recording.metadata, annotations=new_annotations) diff --git a/src/ria_toolkit_oss/annotations/qualify_slice.py b/src/ria_toolkit_oss/annotations/qualify_slice.py new file mode 100644 index 0000000..10ff369 --- /dev/null +++ b/src/ria_toolkit_oss/annotations/qualify_slice.py @@ -0,0 +1,35 @@ +import numpy as np + +from utils.data import Recording + + +def qualify_slice_from_annotations(recording: Recording, slice_length: int): + """ + Slice a recording into many smaller recordings, + discarding any slices which do not have annotations that apply to those samples. + Used together with an annotation based qualifier. + + :param recording: The recording to slice. + :type recording: Recording + :param slice_length: The length in samples of a slice. + :type slice_length: int""" + + if len(recording.annotations) == 0: + print("Warning, no annotations.") + + annotation_mask = np.zeros(len(recording.data[0])) + + for annotation in recording.annotations: + annotation_mask[annotation.sample_start : annotation.sample_start + annotation.sample_count] = 1 + + output_recordings = [] + + for i in range((len(recording.data[0]) // slice_length) - 1): + start_index = slice_length * i + end_index = slice_length * (i + 1) + + if 1 in annotation_mask[start_index:end_index]: + sl = recording.data[:, start_index:end_index] + output_recordings.append(Recording(data=sl, metadata=recording.metadata)) + + return output_recordings diff --git a/src/ria_toolkit_oss/annotations/signal_isolation.py b/src/ria_toolkit_oss/annotations/signal_isolation.py new file mode 100644 index 0000000..8d6c9ac --- /dev/null +++ b/src/ria_toolkit_oss/annotations/signal_isolation.py @@ -0,0 +1,97 @@ +import numpy as np +from scipy.signal import butter, lfilter + +from utils.data.annotation import Annotation +from utils.data.recording import Recording + + +def isolate_signal(recording: Recording, annotation: Annotation) -> Recording: + """ + Slice, filter and frequency shift the input recording according to the bounding box defined by the annotation. + + :param recording: The input Recording to be sliced. + :type recording: Recording + :param annotation: The Annotation object defining the area of the recording to isolate. + :type annotation: Annotation + :param decimate: Decimate the input signal after filtering to reduce the sample rate. + :type decimate: bool + + :returns: The subsection of the original recording defined by the annotation. + :rtype: Recording""" + + sample_start = max(0, annotation.sample_start) + sample_stop = min(len(recording), annotation.sample_start + annotation.sample_count) + + anno_base_center_freq = (annotation.freq_lower_edge + annotation.freq_upper_edge) / 2 - recording.metadata.get( + "center_frequency", 0 + ) + + anno_bw = annotation.freq_upper_edge - annotation.freq_lower_edge + + signal_slice = recording.data[0, sample_start:sample_stop] + + # normalize + signal_slice = signal_slice / np.max(np.abs(signal_slice)) + + isolation_bw = anno_bw + + # frequency shift the center of the box about zero + shifted_signal_slice = frequency_shift_iq_samples( + iq_samples=signal_slice, + sample_rate=recording.metadata["sample_rate"], + shift_frequency=-1 * anno_base_center_freq, + ) + + # filter + if isolation_bw < recording.metadata["sample_rate"] - 1: + filtered_signal = apply_complex_lowpass_filter( + signal=shifted_signal_slice, cutoff_frequency=isolation_bw, sample_rate=recording.metadata["sample_rate"] + ) + + else: + filtered_signal = shifted_signal_slice + + output = Recording(data=[filtered_signal], metadata=recording.metadata) + return output + + +def frequency_shift_iq_samples(iq_samples, sample_rate, shift_frequency): + # Number of samples + num_samples = len(iq_samples) + + # Create a time vector from 0 to the total duration in seconds + time_vector = np.arange(num_samples) / sample_rate + + # Generate the complex exponential for the frequency shift + complex_exponential = np.exp(1j * 2 * np.pi * shift_frequency * time_vector) + + # Apply the frequency shift to the IQ samples + shifted_samples = iq_samples * complex_exponential + + return shifted_samples + + +# Function to apply a lowpass Butterworth filter to a complex signal +def apply_complex_lowpass_filter(signal, cutoff_frequency, sample_rate, order=5): + # Design the lowpass filter + b, a = design_complex_lowpass_filter(cutoff_frequency, sample_rate, order) + + # Apply the lowpass filter + filtered_signal = lfilter(b, a, signal) + return filtered_signal + + +def design_complex_lowpass_filter(cutoff_frequency, sample_rate, order=5): + # Nyquist frequency for complex signals is the sample rate + nyquist = sample_rate + + # Ensure the cutoff frequency is positive and within the Nyquist limit + if cutoff_frequency <= 0 or cutoff_frequency > nyquist: + raise ValueError("Cutoff frequency must be between 0 and the Nyquist frequency.") + + # Normalize the cutoff frequency to the Nyquist frequency + cutoff_normalized = cutoff_frequency / nyquist + + # Create a Butterworth lowpass filter + b, a = butter(order, cutoff_normalized, btype="low") + return b, a diff --git a/src/ria_toolkit_oss/annotations/threshold_qualifier.py b/src/ria_toolkit_oss/annotations/threshold_qualifier.py new file mode 100644 index 0000000..200c9e8 --- /dev/null +++ b/src/ria_toolkit_oss/annotations/threshold_qualifier.py @@ -0,0 +1,212 @@ +""" +Temporal signal detection and boundary refinement via Hysteresis Thresholding. + +Provides methods to detect signal bursts in the time domain by triggering on +smoothed power peaks and expanding boundaries to capture the full energy envelope. + +This module implements a **dual-threshold trigger** to solve the 'chatter' +problem in noisy environments, ensuring that signal annotations encapsulate +the entire rise and fall of a burst rather than just the peak. + +**Key Design Decisions**: + +1. **Hysteresis Logic (Dual-Threshold)**: + - **Trigger**: High threshold (`threshold * max_power`) ensures high confidence + in signal presence. + - **Boundary**: Low threshold (`0.5 * trigger`) allows the annotation to + "crawl" outward, capturing the lower-energy start and end of the burst + often missed by simple single-threshold detectors. + +2. **Temporal Smoothing**: Uses a moving average window (`window_size`) prior + - to thresholding. This prevents high-frequency noise spikes from causing + fragmented annotations and provides a more stable estimate of the + signal's power envelope. + +3. **Spectral Profiling**: Once a temporal segment is isolated, the module + - performs an automated FFT analysis. It identifies the **90% spectral + occupancy** to define the frequency boundaries (`f_min`, `f_max`), + allowing the detector to work on narrowband and wideband signals without + manual frequency tuning. + +4. **Baseband/RF Mapping**: Automatically handles the conversion from + - relative FFT bin frequencies to absolute RF frequencies by referencing + `recording.metadata["center_frequency"]`. + +5. **False Positive Mitigation**: Implements a hard minimum duration check + - (10ms) to ignore transient hardware spikes or noise floor fluctuations + that do not constitute a valid signal burst. + +The module is designed to be the primary "first-pass" detector for pulsed +waveforms (like ADS-B, Lora, or bursty FSK) before passing them to +classification or demodulation stages. +""" + +import json +from typing import Optional + +import numpy as np + +from utils.data import Annotation, Recording + + +def _find_ranges(indices, window_size): + """ + Groups individual indices into continuous temporal ranges. + + Args: + indices: Array of indices where the signal exceeded a threshold. + window_size: Maximum gap allowed between indices to consider them part + of the same range. + + Returns: + A list of (start, stop) tuples representing detected signal segments. + """ + + if len(indices) == 0: + return [] + + ranges = [] + + start = indices[0] + in_range = False + + for i in range(1, len(indices)): + # If the gap between current and previous index is within window_size, + # keep the range alive. + if indices[i] - indices[i - 1] <= window_size: + if not in_range: + # Start a new range + start = indices[i - 1] + in_range = True + else: + # Gap is too large; close the current range if one was active. + if in_range: + ranges.append((start, indices[i - 1])) + in_range = False + + # Ensure the final segment is captured if the loop ends while in_range. + if in_range: + ranges.append((start, indices[-1])) + + return ranges + + +def threshold_qualifier( + recording: Recording, + threshold: float, + window_size: Optional[int] = 1024, + label: Optional[str] = None, + annotation_type: Optional[str] = "standalone", +) -> Recording: + """ + Annotate a recording with bounding boxes for regions above a threshold. + Threshold is defined as a fraction of the maximum sample magnitude. + This algorithm searches for samples above the threshold and combines them into ranges if they + are within window_size of each other. + Detects and annotates signals using energy thresholding and spectral analysis. + + The algorithm follows these steps: + 1. Smooths power data using a moving average. + 2. Identifies 'peak' regions exceeding a high trigger threshold. + 3. Uses hysteresis to expand boundaries until power drops below a lower threshold. + 4. Performs an FFT on each segment to determine frequency occupancy. + + Args: + recording: The Recording object containing IQ or real signal data. + threshold: Sensitivity multiplier (0.0 to 1.0) applied to max power. + window_size: Size of the smoothing filter and max gap for merging hits. + label: Custom string label for annotations. + annotation_type: Metadata string for the 'type' field in the annotation. + + Returns: + A new Recording object populated with detected Annotations. + """ + # Extract signal and metadata + sample_data = recording.data[0] + sample_rate = recording.metadata["sample_rate"] + center_frequency = recording.metadata.get("center_frequency", 0) + + # --- 1. SIGNAL CONDITIONING --- + # Convert to power (Magnitude squared) + power_data = np.abs(sample_data) ** 2 + smoothing_window = np.ones(window_size) / window_size + smoothed_power = np.convolve(power_data, smoothing_window, mode="same") + + # Define thresholds based on the global peak of the smoothed signal + max_power = np.max(smoothed_power) + trigger_val = threshold * max_power # High threshold to trigger detection + boundary_val = (threshold / 2) * max_power # Low threshold to define signal edges + + # --- 2. INITIAL DETECTION --- + # Identify indices that strictly exceed the high trigger + indices = np.where(smoothed_power > trigger_val)[0] + initial_ranges = _find_ranges(indices=indices, window_size=window_size) + + annotations = [] + + threshold_base = min(sample_rate, len(sample_data)) + + for start, stop in initial_ranges: + if (stop - start) < (threshold_base * 0.01): + continue + + # --- 3. HYSTERESIS (Boundary Expansion) --- + # Search backward from 'start' until power drops below the low boundary_val + true_start = start + while true_start > 0 and smoothed_power[true_start] > boundary_val: + true_start -= 1 + + # Search forward from 'stop' until power drops below the low boundary_val + true_stop = stop + while true_stop < len(smoothed_power) - 1 and smoothed_power[true_stop] > boundary_val: + true_stop += 1 + + # --- 4. SPECTRAL ANALYSIS (Frequency Detection) --- + signal_segment = sample_data[true_start:true_stop] + if len(signal_segment) > 0: + fft_data = np.abs(np.fft.fftshift(np.fft.fft(signal_segment))) + fft_freqs = np.fft.fftshift(np.fft.fftfreq(len(signal_segment), 1 / sample_rate)) + + # Determine frequency bounds where spectral energy is > 15% of segment peak + spectral_thresh = np.max(fft_data) * 0.15 + sig_indices = np.where(fft_data > spectral_thresh)[0] + + # Ensure the signal has some spectral width before annotating + if len(sig_indices) < 5: + continue + + if len(sig_indices) > 0: + f_min, f_max = fft_freqs[sig_indices[0]], fft_freqs[sig_indices[-1]] + else: + # Default to middle half of bandwidth if no clear peaks found + f_min, f_max = -sample_rate / 4, sample_rate / 4 + else: + f_min, f_max = -sample_rate / 4, sample_rate / 4 + + # --- 5. ANNOTATION GENERATION --- + if label is None: + label = f"{int(threshold*100)}%" + + # Pack metadata for the UI/Downstream processing + comment_data = { + "type": annotation_type, + "generator": "threshold_qualifier", + "params": { + "threshold": threshold, + "window_size": window_size, + }, + } + + anno = Annotation( + sample_start=true_start, + sample_count=true_stop - true_start, + freq_lower_edge=center_frequency + f_min, + freq_upper_edge=center_frequency + f_max, + label=label, + comment=json.dumps(comment_data), + detail={"generator": "hysteresis_qualifier"}, + ) + annotations.append(anno) + + # Return a new Recording object including the new annotations + return Recording(data=recording.data, metadata=recording.metadata, annotations=recording.annotations + annotations) diff --git a/src/ria_toolkit_oss/view/view_signal.py b/src/ria_toolkit_oss/view/view_signal.py index f8d5731..0f2ed33 100644 --- a/src/ria_toolkit_oss/view/view_signal.py +++ b/src/ria_toolkit_oss/view/view_signal.py @@ -6,18 +6,14 @@ from typing import Optional import matplotlib.pyplot as plt import numpy as np from matplotlib import gridspec +from matplotlib.patches import Patch from PIL import Image from scipy.fft import fft, fftshift from scipy.signal import spectrogram from scipy.signal.windows import hann -from ria_toolkit_oss.datatypes.recording import Recording -from ria_toolkit_oss.view.tools import ( - COLORS, - decimate, - extract_metadata_fields, - set_path, -) +from utils.data.recording import Recording +from utils.view.tools import COLORS, decimate, extract_metadata_fields, set_path def get_fft_size(plot_length): @@ -39,6 +35,80 @@ def set_spines(ax, spines): ax.spines["left"].set_visible(False) +def view_annotations( + recording: Recording, + channel: Optional[int] = 0, + output_path: Optional[str] = "images/annotations.png", + title: Optional[str] = "Annotated Spectrogram", + dpi: Optional[int] = 300, + title_fontsize: Optional[int] = 15, + dark: Optional[bool] = True, +) -> None: + # 1. Setup Plotting Environment + plt.close("all") + if dark: + plt.style.use("dark_background") + else: + plt.style.use("default") + + fig, ax = plt.subplots(figsize=(12, 8)) + + complex_signal = recording.data[channel] + sample_rate, center_frequency, _ = extract_metadata_fields(recording.metadata) + annotations = recording.annotations + + # 2. Setup Color Mapping (No more hardcoded yellow fallback!) + # available_colors = [ + # COLORS.get("magenta", "magenta"), + # COLORS.get("accent", "cyan"), + # COLORS.get("light", "white"), + # "lime", + # ] + + palette = ["#FF00FF", "#00FF00", "#00FFFF", "#FFFF00", "#FF8000"] + unique_labels = sorted(list(set(ann.label for ann in annotations if ann.label))) + label_to_color = {label: palette[i % len(palette)] for i, label in enumerate(unique_labels)} + + # 3. Generate Spectrogram + Pxx, freqs, times, im = ax.specgram( + complex_signal, NFFT=256, Fs=sample_rate, Fc=center_frequency, noverlap=128, cmap="twilight" + ) + + # 4. Draw Annotations + for annotation in annotations: + # --- DEFINING VARIABLES FIRST --- + t_start = annotation.sample_start / sample_rate + t_width = annotation.sample_count / sample_rate + f_start = annotation.freq_lower_edge + f_height = annotation.freq_upper_edge - annotation.freq_lower_edge + + # Look up the color for this specific label + ann_color = label_to_color.get(annotation.label, "gray") + + # Draw the Rectangle + rect = plt.Rectangle( + (t_start, f_start), t_width, f_height, linewidth=1.5, edgecolor=ann_color, facecolor="none", alpha=0.8 + ) + ax.add_patch(rect) + + if unique_labels: + legend_elements = [ + Patch(facecolor=label_to_color[label], alpha=0.3, edgecolor=label_to_color[label], label=label) + for label in unique_labels + ] + ax.legend(handles=legend_elements, loc="upper right", framealpha=0.2) + + ax.set_title(title, fontsize=title_fontsize, pad=20) + ax.set_xlabel("Time (s)", fontsize=12) + ax.set_ylabel("Frequency (MHz)", fontsize=12) + ax.grid(alpha=0.1) # Add faint grid + + output_path, _ = set_path(output_path=output_path) + plt.savefig(output_path, dpi=dpi, bbox_inches="tight") + plt.close(fig) + print(f"Professional annotation plot saved to {output_path}") + + def view_channels( recording: Recording, output_path: Optional[str] = "images/signal.png", @@ -209,9 +279,7 @@ def view_sig( ) set_spines(spec_ax, spines) - spec_ax.set_title("Spectrogram", fontsize=subtitle_fontsize) - spec_ax.set_ylabel("Frequency (Hz)") - spec_ax.set_xlabel("Time (s)") + spec_ax.set_title("Spectrogram", loc="center", fontsize=subtitle_fontsize) if iq: iq_ax = plt.subplot(gs[plot_y_indx : plot_y_indx + 2, :]) @@ -295,7 +363,11 @@ def view_sig( set_spines(meta_ax, spines) if logo and os.path.isfile(logo_path): - logo_ax = plt.subplot(gs[plot_y_indx + 2 :, 2]) + # logo_ax = plt.subplot(gs[plot_y_indx:, 2]) + logo_pos = [0.75, 0.05, 0.2, 0.08] + logo_ax = fig.add_axes(logo_pos, anchor="SE", zorder=10) + plot_x_indx = plot_x_indx + 1 + logo_ax.axis("off") try: @@ -314,7 +386,6 @@ def view_sig( hspace=2.5, # Vertical space between subplots ) - # save path handling output_path, _ = set_path(output_path=output_path) plt.savefig(output_path, dpi=dpi) print(f"Saved signal plot to {output_path}") diff --git a/src/ria_toolkit_oss/view/view_signal_simple.py b/src/ria_toolkit_oss/view/view_signal_simple.py index ab56f7d..248486f 100644 --- a/src/ria_toolkit_oss/view/view_signal_simple.py +++ b/src/ria_toolkit_oss/view/view_signal_simple.py @@ -3,6 +3,7 @@ from __future__ import annotations import gc +import json from typing import Optional import matplotlib @@ -11,13 +12,54 @@ import numpy as np from scipy.fft import fft, fftshift from scipy.signal.windows import hann -from ria_toolkit_oss.datatypes.recording import Recording -from ria_toolkit_oss.view.tools import ( - COLORS, - decimate, - extract_metadata_fields, - set_path, -) +from utils.data.recording import Recording +from utils.view.tools import COLORS, decimate, extract_metadata_fields, set_path + + +def _add_annotations(annotations, compact_mode, show_labels, sample_rate_hz, center_freq_hz, ax2): + if annotations and not compact_mode: + for annotation in annotations: + start_idx = annotation.get("core:sample_start", 0) + length = annotation.get("core:sample_count", 0) + start_time = start_idx / sample_rate_hz + end_time = (start_idx + length) / sample_rate_hz + freq_low = annotation.get("core:freq_lower_edge", center_freq_hz - sample_rate_hz / 4) + freq_high = annotation.get("core:freq_upper_edge", center_freq_hz + sample_rate_hz / 4) + comment = annotation.get("core:comment", "{}") + + try: + comment_data = json.loads(comment) if isinstance(comment, str) else comment + ann_type = comment_data.get("type", "unknown") + if ann_type == "intersection": + color = COLORS["success"] + elif ann_type == "parallel": + color = COLORS["primary"] + elif ann_type == "standalone": + color = COLORS["warning"] + else: + color = COLORS["error"] + except Exception: + color = COLORS["error"] + + rect = plt.Rectangle( + (start_time, freq_low), + end_time - start_time, + freq_high - freq_low, + color=color, + alpha=0.4, + linewidth=2, + ) + ax2.add_patch(rect) + if show_labels: + label = annotation.get("core:label", "Signal") + ax2.text( + start_time, + freq_high, + label, + color=COLORS["light"], + fontsize=10, + bbox=dict(boxstyle="round,pad=0.2", facecolor=color, alpha=0.7), + ) def _get_nfft_size(signal, fast_mode): @@ -138,6 +180,7 @@ def detect_constellation_symbols(signal: np.ndarray, method: str = "differential def view_simple_sig( recording: Recording, + annotations: Optional[list] = None, output_path: Optional[str] = "images/signal.png", saveplot: Optional[bool] = True, fast_mode: Optional[bool] = False, @@ -261,6 +304,15 @@ def view_simple_sig( ax2.set_title("Spectrogram", loc="left", pad=10) + _add_annotations( + annotations=annotations, + compact_mode=compact_mode, + show_labels=show_labels, + sample_rate_hz=sample_rate_hz, + center_freq_hz=center_freq_hz, + ax2=ax2, + ) + if ax_constellation is not None: constellation_samples = _get_plot_samples(signal=signal, fast_mode=fast_mode, slow_max=50_000, fast_max=20_000) method = "differential" if fast_mode else "combined" @@ -310,7 +362,7 @@ def view_simple_sig( else: plt.tight_layout() if show_title: - plt.subplots_adjust(top=0.90) + plt.subplots_adjust(top=0.92) if saveplot: output_path, extension = set_path(output_path=output_path) From 11d9532b5c7bb6b90df4907739c2620677d3b16d Mon Sep 17 00:00:00 2001 From: fordg1 Date: Tue, 31 Mar 2026 13:34:00 -0400 Subject: [PATCH 07/43] Port annotation system from utils and fix ria package imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Annotations package (new): - Add threshold_qualifier with 3-pass hysteresis detector (Pass 1: strong bursts, Pass 2: weak residual bursts, Pass 3: macro-window faint burst detection), auto window_size scaled to 1ms, channel selection, and stable noise_floor baseline throughout - Add energy_detector, cusum_annotator, parallel_signal_separator, qualify_slice, signal_isolation, annotation_transforms - Add __init__.py exporting the four functions used by the CLI - Fix all imports from utils.data → ria_toolkit_oss.datatypes CLI annotate command (new): - Port full annotate CLI from utils including list, add, remove, clear, energy, cusum, threshold, and separate subcommands - Fix imports from utils.* → ria_toolkit_oss.* and utils_cli.* → ria_toolkit_oss_cli.* - Safe overwrite logic: _annotated files always writable, originals protected; --overwrite writes in-place only on _annotated inputs CLI view command: - Add 'annotations' as a valid --type, wiring view_annotations from view_signal view_signal.py: - Add view_annotations function with blue/purple alternating palette and threshold %-sorted drawing order (lower % renders on top) recording.py (datatypes): - Fix lazy imports in to_wav() and to_blue() from utils.io → ria_toolkit_oss.io io/recording.py: - Add compatibility shim in from_npy to remap utils.data.annotation.Annotation to ria_toolkit_oss.datatypes.annotation.Annotation when loading .npy files pickled by the utils package --- .../datatypes/radio_datasets.rst | 8 +- src/ria_toolkit_oss/annotations/__init__.py | 4 + .../annotations/annotation_transforms.py | 55 ++ .../annotations/cusum_annotator.py | 203 +++++ .../annotations/energy_detector.py | 438 ++++++++++ .../annotations/parallel_signal_separator.py | 435 ++++++++++ .../annotations/qualify_slice.py | 35 + .../annotations/signal_isolation.py | 97 +++ .../annotations/threshold_qualifier.py | 352 ++++++++ src/ria_toolkit_oss/datatypes/recording.py | 4 +- src/ria_toolkit_oss/io/recording.py | 21 + .../Qoherent-logo-black-transparent.png | Bin 92294 -> 130 bytes .../Qoherent-logo-white-transparent.png | Bin 19826 -> 130 bytes src/ria_toolkit_oss/view/view_signal.py | 78 ++ .../ria_toolkit_oss/annotate.py | 820 ++++++++++++++++++ .../ria_toolkit_oss/commands.py | 1 + .../ria_toolkit_oss/generate.py | 4 +- .../ria_toolkit_oss/transform.py | 6 +- .../ria_toolkit_oss/view.py | 3 +- tests/ria_toolkit_oss_cli/README.md | 2 +- tests/ria_toolkit_oss_cli/__init__.py | 2 +- 21 files changed, 2554 insertions(+), 14 deletions(-) create mode 100644 src/ria_toolkit_oss/annotations/__init__.py create mode 100644 src/ria_toolkit_oss/annotations/annotation_transforms.py create mode 100644 src/ria_toolkit_oss/annotations/cusum_annotator.py create mode 100644 src/ria_toolkit_oss/annotations/energy_detector.py create mode 100644 src/ria_toolkit_oss/annotations/parallel_signal_separator.py create mode 100644 src/ria_toolkit_oss/annotations/qualify_slice.py create mode 100644 src/ria_toolkit_oss/annotations/signal_isolation.py create mode 100644 src/ria_toolkit_oss/annotations/threshold_qualifier.py create mode 100644 src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py diff --git a/docs/source/ria_toolkit_oss/datatypes/radio_datasets.rst b/docs/source/ria_toolkit_oss/datatypes/radio_datasets.rst index 149fbaf..95d47e2 100644 --- a/docs/source/ria_toolkit_oss/datatypes/radio_datasets.rst +++ b/docs/source/ria_toolkit_oss/datatypes/radio_datasets.rst @@ -11,15 +11,15 @@ The Radio Dataset Framework provides a software interface to access and manipula the need for users to interface with the source files directly. Instead, users initialize and interact with a Python object, while the complexities of efficient data retrieval and source file manipulation are managed behind the scenes. -Utils includes an abstract class called :py:obj:`ria_toolkit_oss.datatypes.datasets.RadioDataset`, which defines common properties and +Ria Toolkit OSS includes an abstract class called :py:obj:`ria_toolkit_oss.datatypes.datasets.RadioDataset`, which defines common properties and behaviors for all radio datasets. :py:obj:`ria_toolkit_oss.datatypes.datasets.RadioDataset` can be considered a blueprint for all other radio dataset classes. This class is then subclassed to define more specific blueprints for different types of radio datasets. For example, :py:obj:`ria_toolkit_oss.datatypes.datasets.IQDataset`, which is tailored for machine learning tasks involving the processing of signals represented as IQ (In-phase and Quadrature) samples. -Then, in the various project backends, there are concrete dataset classes, which inherit from both Utils and the base +Then, in the various project backends, there are concrete dataset classes, which inherit from both Ria Toolkit OSS and the base dataset class from the respective backend. For example, the :py:obj:`TorchIQDataset` class extends both -:py:obj:`ria_toolkit_oss.datatypes.datasets.IQDataset` from Utils and :py:obj:`torch.ria_toolkit_oss.datatypes.IterableDataset` from +:py:obj:`ria_toolkit_oss.datatypes.datasets.IQDataset` from Ria Toolkit OSS and :py:obj:`torch.ria_toolkit_oss.datatypes.IterableDataset` from PyTorch, providing a concrete dataset class tailored for IQ datasets and optimized for the PyTorch backend. Dataset initialization @@ -130,7 +130,7 @@ Dataset processing and manipulation ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ All radio datasets support methods tailored specifically for radio processing. These methods are backend-independent, -inherited from the blueprints in Utils like :py:obj:`ria_toolkit_oss.datatypes.datasets.RadioDataset`. +inherited from the blueprints in Ria Toolkit OSS like :py:obj:`ria_toolkit_oss.datatypes.datasets.RadioDataset`. For example, we can trim down the length of the examples from 1,024 to 512 samples, and then augment the dataset: diff --git a/src/ria_toolkit_oss/annotations/__init__.py b/src/ria_toolkit_oss/annotations/__init__.py new file mode 100644 index 0000000..e64c37f --- /dev/null +++ b/src/ria_toolkit_oss/annotations/__init__.py @@ -0,0 +1,4 @@ +from .cusum_annotator import annotate_with_cusum +from .energy_detector import detect_signals_energy +from .parallel_signal_separator import split_recording_annotations +from .threshold_qualifier import threshold_qualifier diff --git a/src/ria_toolkit_oss/annotations/annotation_transforms.py b/src/ria_toolkit_oss/annotations/annotation_transforms.py new file mode 100644 index 0000000..47300c1 --- /dev/null +++ b/src/ria_toolkit_oss/annotations/annotation_transforms.py @@ -0,0 +1,55 @@ +from ria_toolkit_oss.datatypes.annotation import Annotation + +# TODO figure out how to transfer labels in the merge case + + +def remove_contained_boxes(annotations: list[Annotation]): + """ + Remove all annotations (bounding boxes) that are entirely contained within other boxes in the list. + + :param annotations: A list of Annotation objects. + :type annotations: list[Annotation] + + :returns: A new list of Annotation objects. + :rtype: list[Annotation]""" + + output_boxes = [] + + for i in range(len(annotations)): + contained = False + for j in range(len(annotations)): + if i != j and is_annotation_contained(annotations[i], annotations[j]): + contained = True + break + + if not contained: + output_boxes.append(annotations[i]) + + return output_boxes + + +def is_annotation_contained(inner: Annotation, outer: Annotation) -> bool: + """ + Check if an annotation box is entirely contained within another annotation bounding box. + + :param inner: The inner box. + :type inner: Annotation. + :param outer: The outer box. + :type outer: Annotation. + + :returns: True if inner is within outer, false otherwise. + :rtype: bool + """ + + inner_sample_stop = inner.sample_start + inner.sample_count + outer_sample_stop = outer.sample_start + outer.sample_count + + if inner.sample_start > outer.sample_start and inner_sample_stop < outer_sample_stop: + if inner.freq_lower_edge > outer.freq_lower_edge and inner.freq_upper_edge < outer.freq_upper_edge: + return True + + return False + + +def merge_annotations(annotations: list[Annotation], overlap_threshold) -> list[Annotation]: + raise NotImplementedError diff --git a/src/ria_toolkit_oss/annotations/cusum_annotator.py b/src/ria_toolkit_oss/annotations/cusum_annotator.py new file mode 100644 index 0000000..d37186c --- /dev/null +++ b/src/ria_toolkit_oss/annotations/cusum_annotator.py @@ -0,0 +1,203 @@ +import json +from typing import Optional + +import numpy as np + +from ria_toolkit_oss.datatypes import Annotation, Recording + + +def annotate_with_cusum( + recording: Recording, + label: Optional[str] = "segment", + window_size: Optional[int] = 1, + min_duration: Optional[float] = None, + tolerance: Optional[int] = None, + annotation_type: Optional[str] = "standalone", +): + """ + Add annotations that divide the recording into distinct time segments. + + This algorithm computes the cumulative sum of the sample magnitudes and + determines break points in the signal. + + This tool can be used to find points where a signal turns on or off, or + changes between a low and high amplitude. + + :param recording: A ``Recording`` object to annotate. + :type recording: ``ria_toolkit_oss.datatypes.Recording`` + :param label: Label for the detected segments. + :type label: str + :param window_size: The length (in samples) of the moving average window. + :type window_size: int + :param min_duration: The minimum duration (in ms) of a segment. + The algorithm will not produce annotations shorter than this length. + :type min_duration: float + :param tolerance: The minimum length (in samples) of a segment. + :type tolerance: int + :param annotation_type: Annotation type (standalone, parallel, intersection). + :type annotation_type: str + """ + + sample_rate = recording.metadata["sample_rate"] + center_frequency = recording.metadata.get("center_frequency", 0) + + # Create an object of the time segmenter + time_segmenter = TimeSegmenter(sample_rate, min_duration, window_size, tolerance) + + change_points = time_segmenter.apply(recording.data[0]) + + time_segments_indices = np.append(np.insert(change_points, 0, 0), len(recording.data[0])) + annotations = [] + for i in range(len(time_segments_indices) - 1): + # Build comment JSON with type metadata + comment_data = { + "type": annotation_type, + "generator": "cusum_annotator", + "params": { + "window_size": window_size, + "min_duration": min_duration, + "tolerance": tolerance, + }, + } + f_min, f_max = detect_frequency( + signal=recording.data[0], + start=time_segments_indices[i], + stop=time_segments_indices[i + 1], + sample_rate=sample_rate, + ) + + annotations.append( + Annotation( + sample_start=time_segments_indices[i], + sample_count=time_segments_indices[i + 1] - time_segments_indices[i], + freq_lower_edge=center_frequency + f_min, + freq_upper_edge=center_frequency + f_max, + label=label, + comment=json.dumps(comment_data), + detail={"generator": "cusum_annotator"}, + ) + ) + + return Recording(data=recording.data, metadata=recording.metadata, annotations=recording.annotations + annotations) + + +def _compute_cusum(_signal, sample_rate: int, tolerance: int = None, min_duration: float = -1): + """ + This function efficiently computes the cumulative sum of a give list (_signal), with an optional tolerance. + + Args: + - _signal: array of iq samples. + - Tolerance: the least acceptable length of a block, Defaults to None. + + Returns: + - cusum (array): Array of the cumulative sum of the given list + - sample_rate (int): __description_ + - change_points (array): Array of the indices at which a change in the CUSUM direction happens. + - min_duration (float): The least acceptable time width of each segment (in ms). Defaults to -1. + """ + + # efficiently calculate the running sum of the signal + # cusum = list(itertools.accumulate((_signal - np.mean(_signal)))) + x = _signal - np.mean(_signal) + cusum = np.cumsum(x) + + # 'diff' computes the differences between the consecutive values, + # then 'sign' determines if it is +ve or -ve. + change_indicators = np.sign(np.diff(cusum)) + change_points = np.where(np.diff(change_indicators))[0] + 1 + + # Limit the change_points + # Reject those whose number of samples < minimum accepted #n of samples in (min duration) ms. + if min_duration is not None and min_duration > 0: + min_samples_wide = int(min_duration * sample_rate / 1000) + segments_lengths = np.diff(change_points) + segments_lengths = np.insert(segments_lengths, 0, change_points[0]) + change_points = change_points[np.where(segments_lengths > min_samples_wide)[0]] + return cusum, change_points + + +def detect_frequency(signal, start, stop, sample_rate): + signal_segment = signal[start:stop] + if len(signal_segment) > 0: + fft_data = np.abs(np.fft.fftshift(np.fft.fft(signal_segment))) + fft_freqs = np.fft.fftshift(np.fft.fftfreq(len(signal_segment), 1 / sample_rate)) + + # Use a spectral threshold to find the 'height' of the orange block + spectral_thresh = np.max(fft_data) * 0.15 + sig_indices = np.where(fft_data > spectral_thresh)[0] + + if len(sig_indices) > 4: + return fft_freqs[sig_indices[0]], fft_freqs[sig_indices[-1]] + else: + return -sample_rate / 4, sample_rate / 4 + else: + return -sample_rate / 4, sample_rate / 4 + + +class TimeSegmenter: + """Time Segmenter class, it creates a segmenter object with certain\ + characteristics to easily split an input signal to segments based on\ + the cumulative sum of deviations (of the signal mean) + """ + + def __init__( + self, sample_rate: int, min_duration: float = 1, moving_average_window: int = 3, tolerance: int = None + ): + """_summary_ + + Args: + sample_rate (int): _description_ + min_duration (float, optional): _description_. Defaults to 1. + moving_average_window (int, optional): _description_. Defaults to 3. + tolerance (int, optional): _description_. Defaults to None. + """ + self.sample_rate = sample_rate + self.min_duration = min_duration + self.moving_average_window = moving_average_window + self._moving_avg_filter = self._init_filter() + self.tolerance = tolerance + + def _init_filter(self): + """_summary_ + + Returns: + _type_: _description_ + """ + return np.ones(self.moving_average_window) / self.moving_average_window + + def _apply_filter(self, iqsignal: np.array): + """_summary_ + + Args: + iqsignal (np.array): _description_ + + Returns: + _type_: _description_ + """ + return np.convolve(abs(iqsignal), self._moving_avg_filter, mode="same") + + def _create_segments(self, iq_signal: np.array, change_points: np.array): + """_summary_ + + Args: + iq_signal (np.array): _description_ + change_points (np.array): _description_ + + Returns: + _type_: _description_ + """ + return np.split(iq_signal, change_points) + + def apply(self, iq_signal: np.array): + """_summary_ + + Args: + iq_signal (np.array): _description_ + + Returns: + _type_: _description_ + """ + smoothed_signal = self._apply_filter(iq_signal) + _, change_points = _compute_cusum(smoothed_signal, self.sample_rate, self.tolerance, self.min_duration) + # segments = self._create_segments(iq_signal, change_points) + return change_points diff --git a/src/ria_toolkit_oss/annotations/energy_detector.py b/src/ria_toolkit_oss/annotations/energy_detector.py new file mode 100644 index 0000000..98329d5 --- /dev/null +++ b/src/ria_toolkit_oss/annotations/energy_detector.py @@ -0,0 +1,438 @@ +""" +Energy-based signal detection and bandwidth analysis. + +Provides automatic annotation generation using energy-based signal detection +and occupied bandwidth calculation following ITU-R SM.328 standard. +""" + +import json +from typing import Tuple + +import numpy as np +from scipy.signal import filtfilt + +from ria_toolkit_oss.datatypes import Annotation, Recording + + +def detect_signals_energy( + recording: Recording, + k: int = 10, + threshold_factor: float = 1.2, + window_size: int = 200, + min_distance: int = 5000, + label: str = "signal", + annotation_type: str = "standalone", + freq_method: str = "nbw", + nfft: int = None, + obw_power: float = 0.99, +) -> Recording: + """ + Detect signal bursts using energy-based method with adaptive noise floor estimation. + + This algorithm smooths the signal with a moving average filter, estimates the noise + floor from k segments, applies a threshold to detect regions above noise, and merges + nearby detections. Detected time boundaries are then assigned frequency bounds based + on the selected frequency method. + + Time Detection Algorithm: + 1. Smooth signal using moving average (envelope detection) + 2. Divide smoothed signal into k segments + 3. Estimate noise floor as median of segment mean powers + 4. Detect regions where power exceeds threshold_factor * noise_floor + 5. Merge regions closer than min_distance samples + + Frequency Bounding (freq_method): + - 'nbw': Nominal bandwidth (OBW + center frequency) - DEFAULT + - 'obw': Occupied bandwidth (99.99% power, includes siedelobes) + - 'full-detected': Lowest to highest spectral component + - 'full-bandwidth': Entire Nyquist span (center_freq ± sample_rate/2) + + :param recording: Recording to analyze + :type recording: Recording + :param k: Number of segments for noise floor estimation (default: 10) + :type k: int + :param threshold_factor: Threshold multiplier above noise floor (typical: 1.2-2.0, default: 1.2) + :type threshold_factor: float + :param window_size: Moving average window size in samples (default: 200) + :type window_size: int + :param min_distance: Minimum distance between separate signals in samples (default: 5000) + :type min_distance: int + :param label: Label for detected annotations (default: "signal") + :type label: str + :param annotation_type: Annotation type (standalone, parallel, intersection, default: standalone) + :type annotation_type: str + :param freq_method: How to calculate frequency bounds (default: 'nbw') + :type freq_method: str + :param nfft: FFT size for frequency calculations (default: None) + :type nfft: int + :param obw_power: Power percentage for OBW (0.9999 = 99.99%, default: 0.99) + :type obw_power: float + + :returns: New Recording with added annotations + :rtype: Recording + + **Example**:: + + >>> from ria.io import load_recording + >>> from ria_toolkit_oss.annotations import detect_signals_energy + >>> recording = load_recording("capture.sigmf") + + >>> # Detect with NBW frequency bounds (default, best for real signals) + >>> annotated = detect_signals_energy(recording, label="burst") + + >>> # Detect with OBW (more conservative, includes siedelobes) + >>> annotated = detect_signals_energy( + ... recording, label="burst", freq_method="obw" + ... ) + + >>> # Detect with full detected range (captures all spectral components) + >>> annotated = detect_signals_energy( + ... recording, label="burst", freq_method="full-detected" + ... ) + """ + # Extract signal data (use first channel only) + signal = recording.data[0] + + # Calculate smoothed signal power + kernel = np.ones(window_size) / window_size + smoothed_power = filtfilt(kernel, [1], np.abs(signal) ** 2) + + # Estimate noise floor using segment-based median (robust to signal presence) + segments = np.array_split(smoothed_power, k) + noise_floor = np.median([np.mean(s) for s in segments]) + + # Detect signal boundaries (regions above threshold) + enter = noise_floor * threshold_factor + exit = enter * 0.8 + boundaries = [] + start = None + active = False + + for i, p in enumerate(smoothed_power): + if not active and p > enter: + start = i + active = True + elif active and p < exit: + boundaries.append((start, i - start)) + active = False + + if active: + boundaries.append((start, len(smoothed_power) - start)) + + # Merge boundaries that are closer than min_distance + merged_boundaries = [] + if boundaries: + start, length = boundaries[0] + for next_start, next_length in boundaries[1:]: + if next_start - (start + length) < min_distance: + # Merge with current boundary + length = next_start + next_length - start + else: + # Save current and start new boundary + merged_boundaries.append((start, length)) + start, length = next_start, next_length + # Add final boundary + merged_boundaries.append((start, length)) + + # Create annotations from detected boundaries + sample_rate = recording.metadata["sample_rate"] + center_frequency = recording.metadata.get("center_frequency", 0) + + # Validate frequency method + valid_freq_methods = ["nbw", "obw", "full-detected", "full-bandwidth"] + if freq_method not in valid_freq_methods: + raise ValueError(f"Invalid freq_method '{freq_method}'. " f"Must be one of: {', '.join(valid_freq_methods)}") + + annotations = [] + for start_sample, sample_count in merged_boundaries: + # Calculate frequency bounds based on method + freq_lower, freq_upper = calculate_frequency_bounds( + freq_method, center_frequency, sample_rate, nfft, signal, start_sample, sample_count, obw_power + ) + # Build comment JSON with type metadata + comment_data = { + "type": annotation_type, + "generator": "energy_detector", + "freq_method": freq_method, + "params": { + "threshold_factor": threshold_factor, + "window_size": window_size, + "noise_floor": float(noise_floor), + "threshold": float(enter), + }, + } + + anno = Annotation( + sample_start=start_sample, + sample_count=sample_count, + freq_lower_edge=freq_lower, + freq_upper_edge=freq_upper, + label=label, + comment=json.dumps(comment_data), + detail={"generator": "energy_detector", "freq_method": freq_method}, + ) + annotations.append(anno) + + return Recording(data=recording.data, metadata=recording.metadata, annotations=recording.annotations + annotations) + + +def calculate_occupied_bandwidth( + signal: np.ndarray, + sampling_rate: float, + nfft: int = None, + power_percentage: float = 0.99, +): + if nfft is None: + nfft = max(65536, 2 ** int(np.floor(np.log2(len(signal))))) + + window = np.blackman(len(signal)) + spec = np.fft.fftshift(np.fft.fft(signal * window, n=nfft)) + + psd = np.abs(spec) ** 2 + psd = psd / psd.sum() # normalize + + freqs = np.fft.fftshift(np.fft.fftfreq(nfft, 1 / sampling_rate)) + + cdf = np.cumsum(psd) + + tail = (1 - power_percentage) / 2 + + lower_idx = np.searchsorted(cdf, tail) + upper_idx = np.searchsorted(cdf, 1 - tail) + + return freqs[upper_idx] - freqs[lower_idx], freqs[lower_idx], freqs[upper_idx] + + +def calculate_nominal_bandwidth( + signal: np.ndarray, + sampling_rate: float, + nfft: int = None, + power_percentage: float = 0.99, +) -> Tuple[float, float]: + """ + Calculate nominal bandwidth and center frequency. + + Nominal bandwidth (NBW) is the occupied bandwidth along with the center + frequency of the signal's spectral occupancy. Useful for characterizing + signals with unknown or drifting center frequencies. + + :param signal: Complex IQ signal samples + :type signal: np.ndarray + :param sampling_rate: Sample rate in Hz + :type sampling_rate: float + :param nfft: FFT size + :type nfft: int + :param power_percentage: Fraction of power to contain + :type power_percentage: float + + :returns: Tuple of (nominal_bandwidth_hz, center_frequency_hz) + :rtype: Tuple[float, float] + + **Example**:: + + >>> from utils.annotations import calculate_nominal_bandwidth + >>> nbw, center = calculate_nominal_bandwidth(signal, sampling_rate=10e6) + >>> print(f"NBW: {nbw/1e6:.3f} MHz, Center: {center/1e6:.3f} MHz") + """ + bw, lower_freq, upper_freq = calculate_occupied_bandwidth(signal, sampling_rate, nfft, power_percentage) + + # Center frequency is midpoint of occupied band + center_freq = (lower_freq + upper_freq) / 2 + + return lower_freq, upper_freq, center_freq + + +def calculate_full_detected_bandwidth( + signal: np.ndarray, + sampling_rate: float, + nfft: int = None, + start_offset: int = 1000, +) -> Tuple[float, float, float]: + """ + Calculate frequency range from lowest to highest spectral component. + + Unlike OBW/NBW which define a power-based bandwidth, this calculates + the absolute frequency span from the lowest non-zero spectral component + to the highest non-zero component. + + Useful for: + - Signals with spectral gaps + - Multiple parallel signals (captures all of them) + - Understanding total occupied spectrum vs. actual bandwidth + + :param signal: Complex IQ signal samples + :type signal: np.ndarray + :param sampling_rate: Sample rate in Hz + :type sampling_rate: float + :param nfft: FFT size + :type nfft: int + :param start_offset: Skip samples at start + :type start_offset: int + + :returns: Tuple of (bandwidth_hz, lower_freq_hz, upper_freq_hz) + :rtype: Tuple[float, float, float] + + **Example**:: + + >>> # Signal with two components at different frequencies + >>> bw, f_low, f_high = calculate_full_detected_bandwidth( + ... signal, sampling_rate=10e6, nfft=65536 + ... ) + >>> print(f"Full span: {f_low/1e6:.3f} to {f_high/1e6:.3f} MHz") + """ + # Validate input + if len(signal) < nfft + start_offset: + raise ValueError( + f"Signal too short: need {nfft + start_offset} samples, " + f"got {len(signal)}. Reduce nfft or start_offset." + ) + + # Extract segment + signal_segment = signal[start_offset : nfft + start_offset] + + # Compute FFT and power spectral density + freq_spectrum = np.fft.fft(signal_segment, n=nfft) + psd = np.abs(freq_spectrum) ** 2 + + # Shift to center DC + psd_shifted = np.fft.fftshift(psd) + freq_bins = np.fft.fftshift(np.fft.fftfreq(nfft, 1 / sampling_rate)) + + # Find noise floor (mean of lowest 10% of bins) and all bins above noise floor + noise_floor = np.mean(np.sort(psd_shifted)[: int(len(psd_shifted) * 0.1)]) + above_noise = np.where(psd_shifted > noise_floor * 1.5)[0] + + if len(above_noise) == 0: + # No signal above noise, return zero bandwidth + return 0.0, 0.0, 0.0 + + # Get frequency range of signal components + lower_idx = above_noise[0] + upper_idx = above_noise[-1] + + lower_freq = freq_bins[lower_idx] + upper_freq = freq_bins[upper_idx] + + bandwidth = upper_freq - lower_freq + + return bandwidth, lower_freq, upper_freq + + +def annotate_with_obw( + recording: Recording, + label: str = "signal", + annotation_type: str = "standalone", + nfft: int = None, + power_percentage: float = 0.99, +) -> Recording: + """ + Create a single annotation spanning the occupied bandwidth of the entire recording. + + Analyzes the full recording to find its occupied bandwidth and creates an annotation + covering that frequency range for the entire time duration. + + :param recording: Recording to analyze + :type recording: Recording + :param label: Annotation label + :type label: str + :param annotation_type: Annotation type + :type annotation_type: str + :param nfft: FFT size + :type nfft: int + :param power_percentage: Power percentage for OBW calculation + :type power_percentage: float + + :returns: Recording with OBW annotation added + :rtype: Recording + + **Example**:: + + >>> from ria_toolkit_oss.annotations import annotate_with_obw + >>> annotated = annotate_with_obw(recording, label="signal_obw") + """ + signal = recording.data[0] + sample_rate = recording.metadata["sample_rate"] + center_freq = recording.metadata.get("center_frequency", 0) + + # Calculate OBW + obw, lower_offset, upper_offset = calculate_occupied_bandwidth(signal, sample_rate, nfft, power_percentage) + + # Convert baseband offsets to absolute frequencies + freq_lower = center_freq + lower_offset + freq_upper = center_freq + upper_offset + + # Create comment JSON + comment_data = { + "type": annotation_type, + "generator": "obw_annotator", + "obw_hz": float(obw), + "power_percentage": power_percentage, + "params": {"nfft": nfft}, + } + + # Create annotation spanning entire recording + anno = Annotation( + sample_start=0, + sample_count=len(signal), + freq_lower_edge=freq_lower, + freq_upper_edge=freq_upper, + label=label, + comment=json.dumps(comment_data), + detail={"generator": "obw_annotator", "obw_hz": float(obw)}, + ) + + return Recording(data=recording.data, metadata=recording.metadata, annotations=recording.annotations + [anno]) + + +def calculate_frequency_bounds( + freq_method, center_frequency, sample_rate, nfft, signal, start_sample, sample_count, obw_power +): + if freq_method == "full-bandwidth": + # Full Nyquist span + freq_lower = center_frequency - (sample_rate / 2) + freq_upper = center_frequency + (sample_rate / 2) + else: + # Extract segment for frequency analysis + segment_start = start_sample + segment_end = min(start_sample + sample_count, len(signal)) + segment = signal[segment_start:segment_end] + + if nfft is None or len(segment) >= nfft: + if freq_method == "nbw": + # Nominal bandwidth (OBW + center frequency) + try: + lower_freq, upper_freq, _ = calculate_nominal_bandwidth(segment, sample_rate, nfft, obw_power) + freq_lower = center_frequency + lower_freq + freq_upper = center_frequency + upper_freq + except (ValueError, IndexError): + # Fallback if calculation fails + freq_lower = center_frequency - (sample_rate / 2) + freq_upper = center_frequency + (sample_rate / 2) + + elif freq_method == "obw": + # Occupied bandwidth + try: + _, f_lower, f_upper = calculate_occupied_bandwidth(segment, sample_rate, nfft, obw_power) + freq_lower = center_frequency + f_lower + freq_upper = center_frequency + f_upper + except (ValueError, IndexError): + # Fallback if calculation fails + freq_lower = center_frequency - (sample_rate / 2) + freq_upper = center_frequency + (sample_rate / 2) + + elif freq_method == "full-detected": + # Full detected range (lowest to highest component) + try: + _, f_lower, f_upper = calculate_full_detected_bandwidth(segment, sample_rate, nfft) + freq_lower = center_frequency + f_lower + freq_upper = center_frequency + f_upper + except (ValueError, IndexError): + # Fallback if calculation fails + freq_lower = center_frequency - (sample_rate / 2) + freq_upper = center_frequency + (sample_rate / 2) + else: + # Segment too short for FFT, use full bandwidth + freq_lower = center_frequency - (sample_rate / 2) + freq_upper = center_frequency + (sample_rate / 2) + + return freq_lower, freq_upper diff --git a/src/ria_toolkit_oss/annotations/parallel_signal_separator.py b/src/ria_toolkit_oss/annotations/parallel_signal_separator.py new file mode 100644 index 0000000..957cf58 --- /dev/null +++ b/src/ria_toolkit_oss/annotations/parallel_signal_separator.py @@ -0,0 +1,435 @@ +""" +Parallel signal separation for multi-component frequency-offset signals. + +Provides methods to detect and separate overlapping frequency-domain signals +that occupy the same time window but different frequency bands. + +This module implements **spectral peak detection** to identify distinct frequency +components and split single time-domain annotations into frequency-specific +sub-annotations. + +**Key Design Decisions** (per Codex review): + +1. **Complex IQ Support**: Uses `scipy.signal.welch` with `return_onesided=False` + for proper complex signal handling. Window length automatically adapts to + signal length via `nperseg=min(nfft, len(signal))` to handle bursts >> from ria_toolkit_oss.annotations import find_spectral_components + >>> # Detect the two distinct channels (returns relative frequencies) + >>> components = find_spectral_components(signal, sampling_rate=20e6) + >>> print(f"Found {len(components)} components") + Found 2 components + +The module is designed to work with detected time-domain annotations, +allowing splitting of overlapping signals into separate training samples. +""" + +import json +from typing import List, Optional, Tuple + +import numpy as np +from scipy import ndimage +from scipy import signal as scipy_signal + +from ria_toolkit_oss.datatypes import Annotation, Recording + + +def find_spectral_components( + signal_data: np.ndarray, + sampling_rate: float, + nfft: int = 65536, + noise_threshold_db: Optional[float] = None, + min_component_bw: float = 50e3, + time_percentile: float = 70.0, +) -> List[Tuple[float, float, float]]: + """ + Find distinct frequency components using spectral peak detection. + + Identifies separate frequency components in a signal by analyzing the power + spectral density and finding peaks corresponding to distinct signals. This is + useful for separating parallel signals that occupy different frequency bands. + + **Frequency Representation**: Returns frequencies in **baseband/relative** Hz + (centered at 0). To get absolute RF frequencies, add center_frequency_hz from + recording metadata to all returned values. + + Algorithm: + 1. Compute power spectral density using Welch (properly handles complex IQ) + 2. Auto-estimate noise floor from data if not specified + 3. Smooth PSD to reduce spurious peaks + 4. Find local maxima above noise floor + 5. Estimate bandwidth per peak using -3dB (fallback: cumulative power) + 6. Filter components below minimum bandwidth threshold + + :param signal_data: Complex IQ signal samples (np.complex64/128) + :type signal_data: np.ndarray + :param sampling_rate: Sample rate in Hz + :type sampling_rate: float + :param nfft: FFT size / window length for Welch. Automatically capped at + signal length to handle bursts (default: 65536) + :type nfft: int + :param noise_threshold_db: Minimum SNR threshold in dB. If None (default), + auto-estimates as np.percentile(psd_db, 10). + Adapt this across hardware (Pluto: ~-100, ThinkRF: ~-60). + :type noise_threshold_db: Optional[float] + :param min_component_bw: Minimum component bandwidth in Hz (default: 50 kHz) + :type min_component_bw: float + :param power_threshold: Cumulative power threshold for fallback bandwidth + estimation (default: 0.99 = 99% power, like OBW) + :type power_threshold: float + + :returns: List of (center_freq_hz, lower_freq_hz, upper_freq_hz) tuples. + **All frequencies are relative (baseband, 0-centered).** + Add recording metadata['center_frequency'] to get absolute RF frequencies. + :rtype: List[Tuple[float, float, float]] + + :raises ValueError: If signal has fewer than 256 samples + + **Example**:: + + >>> from ria.io import load_recording + >>> from ria_toolkit_oss.annotations import find_spectral_components + >>> recording = load_recording("capture.sigmf") + >>> segment = recording.data[0][start:end] + >>> # Components in relative (baseband) frequency + >>> components = find_spectral_components(segment, sampling_rate=20e6) + >>> for center_rel, lower_rel, upper_rel in components: + ... # Convert to absolute RF frequency + ... center_abs = recording.metadata['center_frequency'] + center_rel + ... print(f"Component @ {center_abs/1e9:.3f} GHz") + """ + # Validate input + min_samples = 256 + if len(signal_data) < min_samples: + raise ValueError(f"Signal too short: need at least {min_samples} samples, " f"got {len(signal_data)}.") + + # Compute PSD using Welch method for complex IQ signals + # CRITICAL: return_onesided=False for proper complex signal handling + nperseg = min(nfft, len(signal_data)) + noverlap = nperseg // 2 + + # --- STFT --- + freqs, times, Zxx = scipy_signal.stft( + signal_data, + fs=sampling_rate, + window="blackman", + nperseg=nperseg, + noverlap=noverlap, + return_onesided=False, + boundary=None, + ) + + # Shift zero freq to center + Zxx = np.fft.fftshift(Zxx, axes=0) + freqs = np.fft.fftshift(freqs) + + # Power spectrogram + power = np.abs(Zxx) ** 2 + power_db = 10 * np.log10(power + 1e-12) + + # --- Aggregate across time robustly --- + # Using percentile instead of mean prevents short signals from being diluted + freq_profile_db = np.percentile(power_db, time_percentile, axis=1) + + # --- Noise floor estimation --- + if noise_threshold_db is None: + noise_threshold_db = np.percentile(freq_profile_db, 20) + + threshold = noise_threshold_db + 3 # 3 dB above noise floor + + # --- Smooth lightly (avoid merging nearby signals) --- + freq_profile_db = ndimage.gaussian_filter1d(freq_profile_db, sigma=1.5) + + # --- Binary mask of significant frequencies --- + mask = freq_profile_db > threshold + + # --- Find contiguous frequency regions --- + labeled, num_features = ndimage.label(mask) + + components = [] + + for region_label in range(1, num_features + 1): + region_indices = np.where(labeled == region_label)[0] + + if len(region_indices) == 0: + continue + + lower_idx = region_indices[0] + upper_idx = region_indices[-1] + + lower_freq = freqs[lower_idx] + upper_freq = freqs[upper_idx] + bw = upper_freq - lower_freq + + if bw < min_component_bw: + continue + + center_freq = (lower_freq + upper_freq) / 2 + components.append((center_freq, lower_freq, upper_freq)) + + return components + + +def split_annotation_by_components( + annotation: Annotation, + signal: np.ndarray, + sampling_rate: float, + center_frequency_hz: float = 0.0, + nfft: int = 65536, + noise_threshold_db: Optional[float] = None, + min_component_bw: float = 50e3, +) -> List[Annotation]: + """ + Split an annotation into multiple annotations by detected frequency components. + + Takes an existing annotation spanning multiple frequency components and + analyzes the frequency content to create separate sub-annotations for + each distinct frequency component. + + **Use case**: Energy detection found a time window with 2-3 parallel WiFi + channels. This function splits it into separate annotations per channel. + + **Frequency Handling**: `find_spectral_components` returns relative (baseband) + frequencies. This function adds `center_frequency_hz` to convert to absolute + RF frequencies for SigMF annotation bounds. This ensures correct frequency + context across baseband and RF domains. + + :param annotation: Original annotation to split + :type annotation: Annotation + :param signal: Full signal array (complex IQ) + :type signal: np.ndarray + :param sampling_rate: Sample rate in Hz + :type sampling_rate: float + :param center_frequency_hz: RF center frequency to add to relative frequencies + from peak detection (default: 0.0 = baseband) + :type center_frequency_hz: float + :param nfft: FFT size for analysis (default: 65536, auto-capped at signal length) + :type nfft: int + :param noise_threshold_db: Noise floor threshold in dB. If None (default), + auto-estimates from data. + :type noise_threshold_db: Optional[float] + :param min_component_bw: Minimum component bandwidth in Hz (default: 50 kHz) + :type min_component_bw: float + + :returns: List of new annotations (one per detected component). + Returns empty list if no components found or segment too short. + :rtype: List[Annotation] + + **Example**:: + + >>> from ria.io import load_recording + >>> from ria_toolkit_oss.annotations import split_annotation_by_components + >>> recording = load_recording("capture.sigmf") + >>> # Original annotation spans multiple channels + >>> original = recording.annotations[0] + >>> # Split using RF center frequency from metadata + >>> components = split_annotation_by_components( + ... original, + ... recording.data[0], + ... recording.metadata['sample_rate'], + ... center_frequency_hz=recording.metadata.get('center_frequency', 0.0) + ... ) + >>> print(f"Split into {len(components)} components") + Split into 2 components + + **Algorithm**: + 1. Extract segment corresponding to annotation time bounds + 2. Find frequency components in that segment (returns relative frequencies) + 3. Add center_frequency_hz to get absolute RF frequencies + 4. Create new annotation for each component + 5. Preserve original metadata (label, type, etc.) + 6. Add component info to comment JSON + + **Notes**: + - Original annotation is not modified + - Returns empty list if segment too short (<256 samples) + - Segments Recording: + """ + Split multiple annotations in a recording by frequency components. + + Processes specified annotations (or all if indices=None), replacing each + with its frequency-separated components. Uses RF center_frequency from + recording metadata for proper absolute frequency conversion. + + :param recording: Recording to process + :type recording: Recording + :param indices: Annotation indices to split (None = all, default: None). + Use indices=[] to skip splitting (returns unchanged recording). + :type indices: Optional[List[int]] + :param nfft: FFT size for spectral analysis (default: 65536, + auto-capped at signal segment length) + :type nfft: int + :param noise_threshold_db: Noise floor threshold in dB. If None (default), + auto-estimates from each segment. + :type noise_threshold_db: Optional[float] + :param min_component_bw: Minimum component bandwidth in Hz (default: 50 kHz). + Components narrower than this are filtered out. + :type min_component_bw: float + + :returns: New Recording with split annotations + :rtype: Recording + + **Example**:: + + >>> from ria.io import load_recording + >>> from ria_toolkit_oss.annotations import split_recording_annotations + >>> recording = load_recording("capture.sigmf") + >>> # Split all annotations + >>> split_rec = split_recording_annotations(recording) + >>> print(f"Original: {len(recording.annotations)} annotations") + >>> print(f"Split: {len(split_rec.annotations)} annotations") + Original: 5 annotations + Split: 9 annotations + + **Algorithm**: + 1. For each annotation in indices (or all if None): + 2. Call split_annotation_by_components with RF center_frequency + 3. If components found, replace annotation with components + 4. If no components found, keep original annotation + 5. Annotations not in indices are kept unchanged + + **Notes**: + - Original recording is not modified + - Returns empty Recording.annotations if recording has no annotations + - RF center_frequency from metadata ensures correct absolute frequencies + - If an annotation can't be split (too short, wrong format), original kept + """ + if indices is None: + # Split all annotations + indices = list(range(len(recording.annotations))) + + if not recording.annotations: + # No annotations to split + return recording + + signal = recording.data[0] + sample_rate = recording.metadata["sample_rate"] + center_frequency = recording.metadata.get("center_frequency", 0.0) + + # Build new annotation list + new_annotations = [] + for i, anno in enumerate(recording.annotations): + if i in indices: + # Attempt to split this annotation + try: + components = split_annotation_by_components( + anno, + signal, + sample_rate, + center_frequency_hz=center_frequency, + nfft=nfft, + noise_threshold_db=noise_threshold_db, + min_component_bw=min_component_bw, + ) + if components: + # Split successful, use components + new_annotations.extend(components) + else: + # No components found, keep original + new_annotations.append(anno) + except Exception: + # Split failed for any reason, keep original + new_annotations.append(anno) + else: + # Not in split list, keep as-is + new_annotations.append(anno) + + return Recording(data=recording.data, metadata=recording.metadata, annotations=new_annotations) diff --git a/src/ria_toolkit_oss/annotations/qualify_slice.py b/src/ria_toolkit_oss/annotations/qualify_slice.py new file mode 100644 index 0000000..2336fe5 --- /dev/null +++ b/src/ria_toolkit_oss/annotations/qualify_slice.py @@ -0,0 +1,35 @@ +import numpy as np + +from ria_toolkit_oss.datatypes import Recording + + +def qualify_slice_from_annotations(recording: Recording, slice_length: int): + """ + Slice a recording into many smaller recordings, + discarding any slices which do not have annotations that apply to those samples. + Used together with an annotation based qualifier. + + :param recording: The recording to slice. + :type recording: Recording + :param slice_length: The length in samples of a slice. + :type slice_length: int""" + + if len(recording.annotations) == 0: + print("Warning, no annotations.") + + annotation_mask = np.zeros(len(recording.data[0])) + + for annotation in recording.annotations: + annotation_mask[annotation.sample_start : annotation.sample_start + annotation.sample_count] = 1 + + output_recordings = [] + + for i in range((len(recording.data[0]) // slice_length) - 1): + start_index = slice_length * i + end_index = slice_length * (i + 1) + + if 1 in annotation_mask[start_index:end_index]: + sl = recording.data[:, start_index:end_index] + output_recordings.append(Recording(data=sl, metadata=recording.metadata)) + + return output_recordings diff --git a/src/ria_toolkit_oss/annotations/signal_isolation.py b/src/ria_toolkit_oss/annotations/signal_isolation.py new file mode 100644 index 0000000..47852ae --- /dev/null +++ b/src/ria_toolkit_oss/annotations/signal_isolation.py @@ -0,0 +1,97 @@ +import numpy as np +from scipy.signal import butter, lfilter + +from ria_toolkit_oss.datatypes.annotation import Annotation +from ria_toolkit_oss.datatypes.recording import Recording + + +def isolate_signal(recording: Recording, annotation: Annotation) -> Recording: + """ + Slice, filter and frequency shift the input recording according to the bounding box defined by the annotation. + + :param recording: The input Recording to be sliced. + :type recording: Recording + :param annotation: The Annotation object defining the area of the recording to isolate. + :type annotation: Annotation + :param decimate: Decimate the input signal after filtering to reduce the sample rate. + :type decimate: bool + + :returns: The subsection of the original recording defined by the annotation. + :rtype: Recording""" + + sample_start = max(0, annotation.sample_start) + sample_stop = min(len(recording), annotation.sample_start + annotation.sample_count) + + anno_base_center_freq = (annotation.freq_lower_edge + annotation.freq_upper_edge) / 2 - recording.metadata.get( + "center_frequency", 0 + ) + + anno_bw = annotation.freq_upper_edge - annotation.freq_lower_edge + + signal_slice = recording.data[0, sample_start:sample_stop] + + # normalize + signal_slice = signal_slice / np.max(np.abs(signal_slice)) + + isolation_bw = anno_bw + + # frequency shift the center of the box about zero + shifted_signal_slice = frequency_shift_iq_samples( + iq_samples=signal_slice, + sample_rate=recording.metadata["sample_rate"], + shift_frequency=-1 * anno_base_center_freq, + ) + + # filter + if isolation_bw < recording.metadata["sample_rate"] - 1: + filtered_signal = apply_complex_lowpass_filter( + signal=shifted_signal_slice, cutoff_frequency=isolation_bw, sample_rate=recording.metadata["sample_rate"] + ) + + else: + filtered_signal = shifted_signal_slice + + output = Recording(data=[filtered_signal], metadata=recording.metadata) + return output + + +def frequency_shift_iq_samples(iq_samples, sample_rate, shift_frequency): + # Number of samples + num_samples = len(iq_samples) + + # Create a time vector from 0 to the total duration in seconds + time_vector = np.arange(num_samples) / sample_rate + + # Generate the complex exponential for the frequency shift + complex_exponential = np.exp(1j * 2 * np.pi * shift_frequency * time_vector) + + # Apply the frequency shift to the IQ samples + shifted_samples = iq_samples * complex_exponential + + return shifted_samples + + +# Function to apply a lowpass Butterworth filter to a complex signal +def apply_complex_lowpass_filter(signal, cutoff_frequency, sample_rate, order=5): + # Design the lowpass filter + b, a = design_complex_lowpass_filter(cutoff_frequency, sample_rate, order) + + # Apply the lowpass filter + filtered_signal = lfilter(b, a, signal) + return filtered_signal + + +def design_complex_lowpass_filter(cutoff_frequency, sample_rate, order=5): + # Nyquist frequency for complex signals is the sample rate + nyquist = sample_rate + + # Ensure the cutoff frequency is positive and within the Nyquist limit + if cutoff_frequency <= 0 or cutoff_frequency > nyquist: + raise ValueError("Cutoff frequency must be between 0 and the Nyquist frequency.") + + # Normalize the cutoff frequency to the Nyquist frequency + cutoff_normalized = cutoff_frequency / nyquist + + # Create a Butterworth lowpass filter + b, a = butter(order, cutoff_normalized, btype="low") + return b, a diff --git a/src/ria_toolkit_oss/annotations/threshold_qualifier.py b/src/ria_toolkit_oss/annotations/threshold_qualifier.py new file mode 100644 index 0000000..338f13c --- /dev/null +++ b/src/ria_toolkit_oss/annotations/threshold_qualifier.py @@ -0,0 +1,352 @@ +""" +Temporal signal detection and boundary refinement via Hysteresis Thresholding. + +Provides methods to detect signal bursts in the time domain by triggering on +smoothed power peaks and expanding boundaries to capture the full energy envelope. + +This module implements a **dual-threshold trigger** to solve the 'chatter' +problem in noisy environments, ensuring that signal annotations encapsulate +the entire rise and fall of a burst rather than just the peak. + +**Key Design Decisions**: + +1. **Hysteresis Logic (Dual-Threshold)**: + - **Trigger**: High threshold (`threshold * max_power`) ensures high confidence + in signal presence. + - **Boundary**: Low threshold (`0.5 * trigger`) allows the annotation to + "crawl" outward, capturing the lower-energy start and end of the burst + often missed by simple single-threshold detectors. + +2. **Temporal Smoothing**: Uses a moving average window (`window_size`) prior + - to thresholding. This prevents high-frequency noise spikes from causing + fragmented annotations and provides a more stable estimate of the + signal's power envelope. + +3. **Spectral Profiling**: Once a temporal segment is isolated, the module + - performs an automated FFT analysis. It identifies the **90% spectral + occupancy** to define the frequency boundaries (`f_min`, `f_max`), + allowing the detector to work on narrowband and wideband signals without + manual frequency tuning. + +4. **Baseband/RF Mapping**: Automatically handles the conversion from + - relative FFT bin frequencies to absolute RF frequencies by referencing + `recording.metadata["center_frequency"]`. + +5. **False Positive Mitigation**: Implements a hard minimum duration check + - (10ms) to ignore transient hardware spikes or noise floor fluctuations + that do not constitute a valid signal burst. + +The module is designed to be the primary "first-pass" detector for pulsed +waveforms (like ADS-B, Lora, or bursty FSK) before passing them to +classification or demodulation stages. +""" + +import json +from typing import Optional + +import numpy as np + +from ria_toolkit_oss.datatypes import Annotation, Recording + + +def _find_ranges(indices, max_gap): + """ + Groups individual indices into continuous temporal ranges. + + Args: + indices: Array of indices where the signal exceeded a threshold. + max_gap: Maximum gap allowed between indices to consider them part + of the same range. + + Returns: + A list of (start, stop) tuples representing detected signal segments. + """ + + if len(indices) == 0: + return [] + + start = indices[0] + prev = indices[0] + ranges = [] + + for i in range(1, len(indices)): + if indices[i] - prev > max_gap: + ranges.append((start, prev)) + start = indices[i] + prev = indices[i] + + ranges.append((start, prev)) + + return ranges + + +def _expand_and_filter_ranges( + smoothed_power: np.ndarray, + initial_ranges: list[tuple[int, int]], + boundary_val: float, + min_duration_samples: int, +) -> list[tuple[int, int]]: + """Apply hysteresis expansion and minimum-duration filtering.""" + out: list[tuple[int, int]] = [] + n = len(smoothed_power) + for start, stop in initial_ranges: + if (stop - start) < min_duration_samples: + continue + + true_start = start + while true_start > 0 and smoothed_power[true_start] > boundary_val: + true_start -= 1 + + true_stop = stop + while true_stop < n - 1 and smoothed_power[true_stop] > boundary_val: + true_stop += 1 + + if (true_stop - true_start) >= min_duration_samples: + out.append((true_start, true_stop)) + return out + + +def _merge_ranges(ranges: list[tuple[int, int]], max_gap: int) -> list[tuple[int, int]]: + """Merge overlapping or near-adjacent ranges.""" + if not ranges: + return [] + ranges = sorted(ranges, key=lambda r: r[0]) + merged = [ranges[0]] + for s, e in ranges[1:]: + last_s, last_e = merged[-1] + if s <= last_e + max_gap: + merged[-1] = (last_s, max(last_e, e)) + else: + merged.append((s, e)) + return merged + + +def _estimate_noise_floor(power: np.ndarray, quantile: float = 20.0) -> float: + """Estimate baseline from the quieter portion of the envelope.""" + return float(np.percentile(power, quantile)) + + +def _estimate_group_gap(sample_rate: float) -> int: + """Use a fixed temporal grouping gap instead of reusing the smoothing window.""" + return max(1, int(0.001 * sample_rate)) + + +def _estimate_spectral_bounds(signal_segment: np.ndarray, sample_rate: float) -> tuple[float, float]: + """Estimate occupied bandwidth from a smoothed magnitude spectrum.""" + if len(signal_segment) == 0: + return -sample_rate / 4, sample_rate / 4 + + window = np.hanning(len(signal_segment)) + windowed = signal_segment * window + + fft_data = np.abs(np.fft.fftshift(np.fft.fft(windowed))) + fft_freqs = np.fft.fftshift(np.fft.fftfreq(len(signal_segment), 1 / sample_rate)) + + # Smooth the spectrum so noise-like wideband bursts form a contiguous mask + # instead of thousands of tiny isolated runs. + spectral_smooth_bins = max(5, min(257, (len(signal_segment) // 512) | 1)) + spectral_kernel = np.ones(spectral_smooth_bins, dtype=np.float64) / spectral_smooth_bins + smoothed_fft = np.convolve(fft_data, spectral_kernel, mode="same") + + spectral_floor = float(np.percentile(smoothed_fft, 20)) + spectral_peak = float(np.max(smoothed_fft)) + spectral_ratio = spectral_peak / max(spectral_floor, 1e-12) + + if spectral_ratio < 1.2: + return -sample_rate / 4, sample_rate / 4 + + spectral_thresh = spectral_floor + 0.1 * (spectral_peak - spectral_floor) + sig_indices = np.where(smoothed_fft > spectral_thresh)[0] + + if len(sig_indices) == 0: + peak_idx = int(np.argmax(smoothed_fft)) + bin_hz = sample_rate / len(signal_segment) + half_bins = max(1, int(np.ceil(10_000.0 / bin_hz))) + lo_idx = max(0, peak_idx - half_bins) + hi_idx = min(len(smoothed_fft) - 1, peak_idx + half_bins) + else: + runs = _find_ranges(sig_indices, max_gap=max(1, spectral_smooth_bins // 2)) + peak_idx = int(np.argmax(smoothed_fft)) + lo_idx, hi_idx = min(runs, key=lambda run: 0 if run[0] <= peak_idx <= run[1] else min(abs(run[0] - peak_idx), abs(run[1] - peak_idx))) + + # Prevent extremely narrow tone boxes from collapsing to just a few bins. + min_total_bw_hz = 20_000.0 + min_half_bins = max(1, int(np.ceil((min_total_bw_hz / 2) / (sample_rate / len(signal_segment))))) + center_idx = int(round((lo_idx + hi_idx) / 2)) + lo_idx = max(0, min(lo_idx, center_idx - min_half_bins)) + hi_idx = min(len(smoothed_fft) - 1, max(hi_idx, center_idx + min_half_bins)) + + return float(fft_freqs[lo_idx]), float(fft_freqs[hi_idx]) + + +def threshold_qualifier( + recording: Recording, + threshold: float, + window_size: Optional[int] = None, + label: Optional[str] = None, + annotation_type: Optional[str] = "standalone", + channel: int = 0, +) -> Recording: + """ + Annotate a recording with bounding boxes for regions above a threshold. + Threshold is defined as a fraction of the maximum sample magnitude. + This algorithm searches for samples above the threshold and combines them into ranges if they + are within window_size of each other. + Detects and annotates signals using energy thresholding and spectral analysis. + + The algorithm follows these steps: + 1. Smooths power data using a moving average. + 2. Identifies 'peak' regions exceeding a high trigger threshold. + 3. Uses hysteresis to expand boundaries until power drops below a lower threshold. + 4. Performs an FFT on each segment to determine frequency occupancy. + + Args: + recording: The Recording object containing IQ or real signal data. + threshold: Sensitivity multiplier (0.0 to 1.0) applied to max power. + window_size: Size of the smoothing filter in samples. Defaults to 1ms worth of samples. + label: Custom string label for annotations. + annotation_type: Metadata string for the 'type' field in the annotation. + channel: Index of the channel to annotate. Defaults to 0. + + Returns: + A new Recording object populated with detected Annotations. + """ + # Extract signal and metadata + sample_data = recording.data[channel] + sample_rate = recording.metadata["sample_rate"] + center_frequency = recording.metadata.get("center_frequency", 0) + + if window_size is None: + window_size = max(64, int(sample_rate * 0.001)) + + # --- 1. SIGNAL CONDITIONING --- + # Convert to power (Magnitude squared) + power_data = np.abs(sample_data) ** 2 + smoothing_window = np.ones(window_size) / window_size + smoothed_power = np.convolve(power_data, smoothing_window, mode="same") + group_gap_samples = _estimate_group_gap(sample_rate) + + # Define thresholds using peak relative to baseline. + max_power = np.max(smoothed_power) + noise_floor = _estimate_noise_floor(smoothed_power) + dynamic_range_ratio = max_power / max(noise_floor, 1e-12) + + # Soft early exit: keep a guard for low-contrast noise, but compute it from + # the quieter tail of the envelope so burst-heavy captures are not rejected. + if dynamic_range_ratio < 1.5: + return Recording(data=recording.data, metadata=recording.metadata, annotations=recording.annotations) + + trigger_val = noise_floor + threshold * (max_power - noise_floor) + boundary_val = noise_floor + 0.5 * threshold * (max_power - noise_floor) + + # --- 2. INITIAL DETECTION --- + # Enforce an explicit minimum duration in seconds; this is stable across + # varying capture lengths and avoids over-fitting to recording length. + min_duration_samples = max(1, int(0.005 * sample_rate)) + annotations = [] + + # Pass 1: Detect stronger bursts. + indices = np.where(smoothed_power > trigger_val)[0] + pass1_initial = _find_ranges(indices=indices, max_gap=group_gap_samples) + pass1_ranges = _expand_and_filter_ranges( + smoothed_power=smoothed_power, + initial_ranges=pass1_initial, + boundary_val=boundary_val, + min_duration_samples=min_duration_samples, + ) + + # Pass 2: Recover weaker bursts on residual power not already covered. + # This improves recall in mixed-amplitude captures. + mask = np.ones_like(smoothed_power, dtype=np.float32) + for s, e in pass1_ranges: + mask[max(0, s) : min(len(mask), e)] = 0.0 + residual_power = smoothed_power * mask + + residual_max = float(np.max(residual_power)) + residual_ratio = residual_max / max(noise_floor, 1e-12) + + pass2_ranges: list[tuple[int, int]] = [] + if residual_ratio >= 2.0: + weak_threshold = max(0.3, threshold * 0.7) + weak_trigger = noise_floor + weak_threshold * (residual_max - noise_floor) + weak_boundary = noise_floor + 0.5 * weak_threshold * (residual_max - noise_floor) + weak_indices = np.where(residual_power > weak_trigger)[0] + pass2_initial = _find_ranges(indices=weak_indices, max_gap=group_gap_samples) + pass2_ranges = _expand_and_filter_ranges( + smoothed_power=smoothed_power, + initial_ranges=pass2_initial, + boundary_val=weak_boundary, + min_duration_samples=min_duration_samples, + ) + + # Pass 3: Detect sustained faint bursts via macro-window averaging. + # Targets bursts whose peak power is near the trigger level but whose + # *average* power is consistently elevated above the noise floor — these + # are missed by peak-based detection because only a few short spikes exceed + # the trigger, all too brief to pass the minimum-duration filter. + # + # The mask is applied to power_data *before* convolving so that bright + # burst energy does not bleed through the long window into adjacent regions, + # which would inflate macro_residual_max and push the trigger above the + # faint burst's average power. + macro_window_size = max(window_size * 16, int(sample_rate * 0.02)) + macro_kernel = np.ones(macro_window_size, dtype=np.float64) / macro_window_size + # Expand each annotated range by half the macro window on both sides so that + # the long convolution cannot "see" the leading/trailing edges of already- + # annotated bursts, which would produce spurious short fragments in Pass 3. + macro_expand = macro_window_size * 2 + masked_power_for_macro = power_data.copy() + n = len(masked_power_for_macro) + for s, e in pass1_ranges + pass2_ranges: + masked_power_for_macro[max(0, s - macro_expand) : min(n, e + macro_expand)] = 0.0 + macro_residual = np.convolve(masked_power_for_macro, macro_kernel, mode="same") + + macro_residual_max = float(np.max(macro_residual)) + + pass3_ranges: list[tuple[int, int]] = [] + if macro_residual_max / max(noise_floor, 1e-12) >= 1.3: + macro_trigger = noise_floor + threshold * (macro_residual_max - noise_floor) + macro_boundary = noise_floor + 0.5 * threshold * (macro_residual_max - noise_floor) + macro_indices = np.where(macro_residual > macro_trigger)[0] + macro_initial = _find_ranges(indices=macro_indices, max_gap=group_gap_samples) + pass3_ranges = _expand_and_filter_ranges( + smoothed_power=macro_residual, + initial_ranges=macro_initial, + boundary_val=macro_boundary, + min_duration_samples=min_duration_samples, + ) + + all_ranges = _merge_ranges(pass1_ranges + pass2_ranges + pass3_ranges, max_gap=group_gap_samples) + + for true_start, true_stop in all_ranges: + + # --- 4. SPECTRAL ANALYSIS (Frequency Detection) --- + signal_segment = sample_data[true_start:true_stop] + f_min, f_max = _estimate_spectral_bounds(signal_segment, sample_rate) + + # --- 5. ANNOTATION GENERATION --- + ann_label = label if label is not None else f"{int(threshold*100)}%" + + # Pack metadata for the UI/Downstream processing + comment_data = { + "type": annotation_type, + "generator": "threshold_qualifier", + "params": { + "threshold": threshold, + "window_size": window_size, + }, + } + + anno = Annotation( + sample_start=true_start, + sample_count=true_stop - true_start, + freq_lower_edge=center_frequency + f_min, + freq_upper_edge=center_frequency + f_max, + label=ann_label, + comment=json.dumps(comment_data), + detail={"generator": "hysteresis_qualifier"}, + ) + annotations.append(anno) + + # Return a new Recording object including the new annotations + return Recording(data=recording.data, metadata=recording.metadata, annotations=recording.annotations + annotations) diff --git a/src/ria_toolkit_oss/datatypes/recording.py b/src/ria_toolkit_oss/datatypes/recording.py index 1faec21..b282d9d 100644 --- a/src/ria_toolkit_oss/datatypes/recording.py +++ b/src/ria_toolkit_oss/datatypes/recording.py @@ -601,7 +601,7 @@ class Recording: >>> recording = Recording(data=samples, metadata=metadata) >>> recording.to_wav() """ - from utils.io.recording import to_wav + from ria_toolkit_oss.io.recording import to_wav return to_wav( recording=self, @@ -651,7 +651,7 @@ class Recording: >>> recording = Recording(data=samples, metadata=metadata) >>> recording.to_blue() """ - from utils.io.recording import to_blue + from ria_toolkit_oss.io.recording import to_blue return to_blue(recording=self, filename=filename, path=path, data_format=data_format, overwrite=overwrite) diff --git a/src/ria_toolkit_oss/io/recording.py b/src/ria_toolkit_oss/io/recording.py index f162be6..0234e11 100644 --- a/src/ria_toolkit_oss/io/recording.py +++ b/src/ria_toolkit_oss/io/recording.py @@ -134,6 +134,27 @@ def from_npy(file: os.PathLike | str, legacy: bool = False) -> Recording: annotations = list(np.load(f, allow_pickle=True)) except EOFError: annotations = [] + except ModuleNotFoundError: + # File was pickled with utils.data.Annotation — remap to ria_toolkit_oss + import pickle + import sys + import types + import ria_toolkit_oss.datatypes.annotation as _ann_mod + + utils_shim = types.ModuleType("utils") + utils_data = types.ModuleType("utils.data") + utils_data_annotation = types.ModuleType("utils.data.annotation") + utils_data_annotation.Annotation = _ann_mod.Annotation + utils_shim.data = utils_data + utils_data.annotation = utils_data_annotation + sys.modules.setdefault("utils", utils_shim) + sys.modules.setdefault("utils.data", utils_data) + sys.modules.setdefault("utils.data.annotation", utils_data_annotation) + + f.seek(0) + np.load(f, allow_pickle=True) # skip data + np.load(f, allow_pickle=True) # skip metadata + annotations = list(np.load(f, allow_pickle=True)) recording = Recording(data=data, metadata=metadata, annotations=annotations) return recording diff --git a/src/ria_toolkit_oss/view/graphics/Qoherent-logo-black-transparent.png b/src/ria_toolkit_oss/view/graphics/Qoherent-logo-black-transparent.png index 8da49e8a8d35a0383b86b5d7001463a78eb42178..807e4efe1fb6ae956178725218544c91b14d93e7 100644 GIT binary patch literal 130 zcmWN_!41P83;@7CQ?Nh-7%*|R2^b1eTcSep==9C&q`UHWwEmHG&SPv!J==V|%2;me z8JE=GY8*MK%ZT1sj=F=#jd#k(4y-rPg|#FsR$D+rxKgmrYg>qdu}(XnvSZ}TkqZ;v NkwA?0%aQ=di9f)bCcgjx literal 92294 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 z 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 >x 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 diff --git a/src/ria_toolkit_oss/view/graphics/Qoherent-logo-white-transparent.png b/src/ria_toolkit_oss/view/graphics/Qoherent-logo-white-transparent.png index a18426e743c09368b889c16eb3b72dd30ea8056f..185600c26a5c15b58d9479110ab44ef9308e4c93 100644 GIT binary patch literal 130 zcmWN_K@Ni;5CFhCuiyg}SYbtfv%qd4X^Sc9p|7u}N#^43X#Jz>aqMF==Gl(NOU86t z&ot%!He=^(E~k1cdQ^gd8|26|y9Ing6fWSigh-ZxC3}~*5YyrySQLyi1-8Cm@NQ)R N5mKZ5Si3a>sz2I3CoKQ~ literal 19826 zcmbrkbyQnlum)NnrJ*4T^IpQUk+DPS^F>GZOrNZ9M0HF|&fP_@|e5!tNp%TV~J_|hgtFh(~ zxAGzucX%6D{5BvjpdUmR-k0(#tVe?LUH_)Z7n+>2-?c^WdJXV#4H0N0@U+p}gKx6t zYy}uu`=_~LxUOk04t@tEe@~e*Dd5tg)5Z9U@?iIiL3vY&rP=?KO)pqu!-sJPmGb_cSAFc48udl<;Yp6=$H_bn~Z)GnB7MtOJ|NM4%BJ@jA zWL(}Dlzo-=S1fBWlERbWg3HtE+>#Wd8u;}UI!pR=OWxi3W4lTn79DlhDh9foK_MYN z9swTSp04TN%$M@rJSKhpS>I6codOSXqp{M=*#w+ud{8BoY^xEJ-e2w%QM4#1zf|PW zk`gMII7A9%sc>s-xN!XK0-J!Bma-1&xS}#~$In0Sw9kwA|KJIs+4CyE;{V#Vh2!-y zhXh9s*T6c(iQ{F{t_e6cn+rR&bu8p;51~0xyvPuD5ru25BSl^ApE9yN!K(|X7IDOpwu#h|Y`MA;b64bh7~I_M zVXgm?&Qp}~4V}XEg1;&rsM*gHEhYxcz^V*e3?S0O1D{~ZY1%T`7n-S1$-V7$RQW)b z{^@=@|8K`6Q!rr%YtTD#rH}YBWAzuqXk`|$rnRg}9&EjC9HDRP$HHj56y83^!E$w> zK5ByZ#H4HI6{y)Kg=GKMsR`%a~L0jYIsF!-VK#B0w%?P}HOSk>l zpd?7moi!D1Yj?`NFx4)Hz_Y6(oETYb_-zN)7Ye0BPr?59(4~=KVgQDm=<5##5q-49 z-RKM|GCu=eh(f=eX;Gk%OfGxF156*k=ZdWZ0iVUFX5jbm#RNhi6)xmHY{!YB&55}F zCr4v{FxT9ddYFzIPpLsMYz37-#fw#qRZ|!eF7(Rb*l+fw+agG0?hBKxwuxsajXqt{ zDDe~xOb!jgPD_L;JJD(P*Y!<@ieYuM&Qg6$pl7PQqQzo!3~<_?M>d;@$Yqdg`PV>5 zX*&*sXQ<>aJ(HlhP#i=BjYacLAZ$q*rr=?!%L_@wDDU}{fEPhDch!N~DEJ)X#G zX|H(YlYoAiCrQM_yTxab%EN%0sZD^)w*i?~Dc^7LhS$azIP70icx4S`i3bk<(#PkF zY?&&$`m!@bp@hQu>7MT$=b|?S3+)%~@?T!W_x5Q{9$ceL`!z-cS%kI02d@DX{5Hxs z6d!Ee&`bX;G0aQq1d_h2X!qePfljd68jNdcMei+ZgFS>K$4bE zk7qf}^BghHW}S}eysTyHBzeC+kd7uO$-!?z5UOHPIbNIO{4$lq1)vE|A}2uUb#V%} zT5)VE?8QiA?3L96@A8*LHz@_G*}{uM+2ZBxxH6??0`FemCZQ@ZmjH`2!OAR!!4Xf= zq-Ba+o2X;30h8&29k`m{^N>_0g*|^o5Ag>fL7W(?$ff_OEQL$C z*Fw~rF&kW#2s*{(Y<437)MLFsr_Z)bQWz1BrDf;hV4}?SP6}ktxa$X)qsw`*;)0V= z5;%&IzP%=ESq#AI8-DkW3oJc!M{t)E#{XRG=Ww4E`Ipr|r>THkH7`8g(tdZY4dPGe z=*eg4C&o;rk{`nz7c;{*@?t7#TM--$POq{j(KZe-*@|p}OE*95%ckOlRd9IcG>w7e2cJucr+Cp!Qyardf!|v2j1>Rmfs3;Ki~-wTD=TNRqOB(cbr7c=#~NqMh*d%|VE}s$|LlX! zGa6DQdt5RV^4=1iXPi{*S&yun_s>JHZU7;D3achu8qTJ{8>$U1uEwX6be#T6-pmj~ z{!MxG%xB*T6UUz3RIhja7{;mxO zbvT{Tt2D}PbfM+&ACEvhx+`M-ES1ih3L}E|Z_+hQdvDioSx0OrB&ON2U~{>fu*71r zzYGri+J|wAn%nns`j4&&jZgbH%3n~SS{>6L9A1#t<_tJuT0J>=Yti(1K5soIKV;FE z&Xx3wmxKxnKG=Q=ug2O>mkBji+LU*e;;eg9fLTHVG!*D2O_Sy{4n(1Oi7xF7VWji0 zL1(6O?ZB zf2vzg#NwuY6!Fq;P*Ft27jOgPRc@8~Ue)?DR1V)H#t+qwiQP17;s4=tK2w0R$CO;sel#U~(8hvN?{g8hrb&!fA0=m(qw$R$E1 zsjM{qk)hzKM+-(epnV}@nA>OvRz4e~c8TA_4H_lZeLnN@^3w=u=u+1^l+e zYvWNPa^U7TOo$O6AhA{o&%1c8wTx1=h85`EALWD$k8U8txdq~^MC6(2EoWa8+B8N5 z(Kf$y?016_3f;rHbCkNEpI`CvE3IjU6*o^FTbiHi_vH2Xk((`8BFEbAME7^5;~qJp`}0b^F$_k4)BMKOF8C*U(f^g`Y_n`xo-qC_r-C5C&)Cq&9t?{neX( z`OSycOT|U0hJyxTY&Hi0LoRQ&2i62CN70m6;h?u&`ekNI+`9HV1K?U|#x;;x08HxSyXkqlSd zAU6yd^XvxKECI$ACi{fYa>Vdq&5UdS`z6;%3Sn z5yYw7Zm!kj#T~7Ee6|Ric6DW$!L;@wkRn)@M|M1cV#JOp;XQ%)2*wVC14S2gJy`|F z>#)l&lLJ(mO~wpQcai8%-<@ygmLD@Z_mtex8vZCojh(2X0NE_$j4-Fu7pY95Bwz#N z>+ygQeIXDOG*dLXEM12u@}g$NKQizqxu9%5gkh5iAG$O5N9#D$N`CWikpyReeecpoiETKiqGFrVoV){I-96Sniw&%f9f_IH@f7==Oj-G zpTtj$m%>w`IsU`Z0_?kz+6*2A1Q{nRGWPB2Gp731hZ8N)1{0}9-maHxOJ?E?rVK@K zN;Wp;Gu;^sez;ENHE>A*Zv>bxhf|I{QDYIue%S#EfNSx)g!;DazPzcUp%Ajf_zR*} zuYI?vio3{dfeLFN_{~tt%M$0OmmseJJkH(se&*S|^-!>b;ytybFaH5u*R;_wiqpT3 zj$B;-GQHdo03ie6gXY- zjmS|e*+>b>S=5@C4dLa~;Tx2T_1afGo8ioyavK?*nnFbQ?Al|+VnaxWS!IYto6X+v zdyJvp+chjPg(fEfHJ9NkpT_|~F&`+x$<&ju34ou$WSRih$R33;BknWq zSf`49-c_ZfbnG2*K~z)|?1d3$rNwI96%VrFM(Ts%S1btUm`)uELP%K}e>?*!3#Y$w z&d-{vhc*k_M3;#%bQiDCkZ07f$q#L{5)=%JDKIk;)sW6A`GFSPo( zb?lt7bcRxo)(FGJWgoj{~6{lCXYx!)@eC_TjIF`FXc^?2!9g8E=iP@ofr~ z5a^8S+xvBFRB8z=JjT32?S8ILTko#-t0@A3uZbZ35WiW8W#ryj3RV-Bw}aZRR?__T zi9Y=PCV>H^DHh6ZN;s}*Lc$tQ*eVH7m&gl9(MeR?w@IBdo3OBQ{TwIDHN-zDOxQq8 zAq!CV;L^C8&fAaU+cE3(NMJ%kc|0H(x$E?}ZnJ@~+WEHaw} zkvu<@)ep7Tpa^HJl1)`{C1@F1T*54SnSiejCw#Y*XAsq+nK-H$SEt@pF;-|`5XWXx zZQk*Wm_b{DS&G84Ume2gy=SHk3&H}?$}9nYzGyT-Le^7r(gE6NtrjPBSS8Y4IA0AC{^fBxmTqx^zY?ikJrUqqRyK+g1Vo+sf(MPCd@g zglwMk$JRN^n=>l|xLrhSyGb^u$?1I~gkQiXTAJwbClacFgZMT{$q`&OHPJCJEX+Vf zi6P`eLc*sI_L1lLEdUdC+9|&o1~G(L>(bS%#P(IGnRITkp){WtVt)z45RlaU1w_Y7C&9pV5t%j=99_z?dxVt zjvzi-CH6a9C?J-$fsrLQvYqnVh%@UpNenBI*|RvcxUs{zd%4KT%iZ?;iQX&Q-8QIX zau4tCQg)dj*BRi<9-8 zZ(R|H5fy^4?t2DfHe-ow{;qBN@V|bM*=scu$VG2>1>N5?Hp{aO6DKPW{e%>(a6|8T z#LadkpbZUS$Azz74P8NG-aOe!3Bi3RZfAbYaM_~@N&9a`J4jYSs+Ji%3vL}H@0JO==D zA3>grufp7W;rvl+gK!3Y`J|u#<1>I0CJ^XyYiPcf@>PPCE=9)BO<0pw9e^6tOW5-k z3?ACx9rK*U!~4;j*42E>eD^x^4YdCl*Dv?~Y~4B_l0CApN8iwR6INj;$nl47!a7B_ z%Kz~)e}sZJRBa@Tfys5Y_0o5d4=25jgm_pv&m2%t0>e)IsngalWNRaxYa8os+;gNC z9i>_rZY}o}b#!KG1an08Gu|yr$FYW*MU%?}omMUQ%PrSrB3kL_v zh$B4E#1#LJolC9{5#*{0bFy9|Z?nN^t`>6qK2SsOfAm8gV|?9$ra1%P%3qb%05Z~> zliKqF03etlKwn%O97HGj-5z64G`$nkV(v&~@vCWH^uFbqXm?kaD9Q0ek0Svdup@I! zECowSLv2bl{q5dPwM}M;*t+N#Z__b%KQeyswD3sRGW;76<{3nzkUJb(y(aGUh*0_A z@77HKDr(##YNdsu-0OHvkNkT|3_14Ini=5$W*oPM`|>VtIks|GR;xqdAp`>ELXV z5i%NGILCL_YkFx+9KkncFmnk`TQ1q@02z1bMUZSrC$)6i{zZl`L^fQy*xG?VbXZga z9}nQCmYsp}E@N(cO9oalIoq`7Dota=Jw2{9%W2ad_d14LY|_SX8HinIQpSuO?BotT z!yfoCG;#uwU5ESWBD^tE7dBhq*XsjC5i{bYE57uaPb6t%GoR+Q*a>{Z3T z#L)+Z5Xec0!-KrUo+Q0Q`#^(Db-w5j-XOZdvdo{Q1m-=e+?PzqqESah7k zs`=^uuHlb*Zb!p$fO%MyPrs2dmO7RxR5%vcDd7}479#@>3ILl-Fmi?gb%;uc%;m&; zY)(tahG^02q+NWuI^KyV^~iAA3IR<-Ky>&KJm2{l+q2R_tFJ!SO}zh=gC{?b#?8*d zSID)0Nk<0;%cc3_Y~E${>m7PGY&)8NO71vkd)6uqb}1HqXHu-B4(unuUPYe(_g7Oo zyOG`MLr}Ullh|z$M%nvNDGEcfr<6<(0XbM+LKsEjTO}!Qk~Z{!>b7aYSvQW%Q_eEV zub#kkTl$AIEL&CsjgC5kwp=)uaE(h|FSax?%7y05_lCqCPl@RWkUAkRj_G`fL_&}k zYEVcklv+5hvbg6EWU9Z@@rgQ9S$iTWJ?z$l(LJzI`CE|0CS zFaV5#v_84L zc`x8oiKnHj;9+Im#6m^I-BF%-F=9k$>HxxcK zAg&^=hBg5Pr=1~uH**ra5yT{~Oi_baD z=8P&y{*9K+OEAN^xYGZE$SKrP?C_&7p-9B45Q?F>WCbAY!+dA!ROwNMVu6SPB}EKi z$?EdvY&`TwhGm|F5zf*-_jxz4P=hdTj_f323E>2IJNuB?kMCn5vuD3&4yqh+WrQ=U z$qqg8611-44rb#`{J(sphm3WJf?o}6Kbu&<$?%m0z`Xo{YMWnIIC20I!f42NVDkqD z7}M~T*#ZPq#1+Kh&*g;zuo%E^z@;Ya?&kW}VBP1)0*K~)P(d9g_XZ8qRz3xVngLvj zf=t~Ql4lBY=RYe9;HN+X4500zBBie9YC_yrc+l9hVXXfCzrt8R>wS#aU;FA3)&FvM zy>N_S^Q@HsZ?-m5@ayLcu5V0%WFh^}B1`lcBK|*_hvW&4Wk<@xILB04TMfTBP znIegP;pEna_%FLH>JX>+%8>!%RdSu_np>8`(WA*CKR}0<-Dx3E9Tb!5}-tIiED&X z#u+pkCP0KR0q;@{3j{X5KmlKubdi>h?piwMwmnR-k2>o9+R^aAs|Y0Ezifom2F;5# zZ~DAarB#$nlx^5Sb>ZT6M*7Cry$ZjFfob30+=jhly_B_XaRv&1lw z6FwGv1pthyWgx0<#T#O3q#^q%n) zwoT7st(^(SewaCFI5SR;q@YjP8VxV0+nZI1F2B@Y#RU=r8a6xEI|TXvZ5-y({I6A5#(6+_?@J(X&s-VRH>74nJBTyBiCFL_}yn0 zGPSz$tvO2R-MkrYR1BM_D6jK`zHlK%zb~B#%W;#s(d-v*FG%h*r@&0M^VkEqWc;#~ zUPHRAav5 z(w5@`PP2dD*8v}gIY$vJ^z4n(lI;=F8p2rR*x>O^$A#@C_GLZDBY*J8CNZ4F>{e#- zOLVYEKpWX@*Rt`@tyiPy^2#-5o7bB(j2)wMAcauP?|}I!L~2M?@7b<+bX;ytN45E?RDYT>KIAC=%_f7#N(&dgr{>>?%uR6IcPywd z>gzoW;6?5Dn!Vuo?AwE)P*r^d#g|E{WiP;^2MBlpe%sr0QtQCKJf?YP`WQ&;ZyrYRWm2tOz{OM&9VGa)ogntD=C zPp#HFyM3cwPxfH{iP51~#os}k-PwC21X3OKF%xjh@o$NLRgbFv86%}_+m58jo*X1{ zl{>>(1K7~j_YXr#K0&Y0Csj>4f+US2#Nau9+bpv5e5z&<|AKJ6%xImnK-YvO%;mY; z#V5Ea;F|sgCY$#W6d&Vg8v;&K36Hax(``GmAs0nmh5Zxhf&NgCqBa5h#&r2DD}7}u z&SY{aNs(bIZ^SQ?p4oRa=FfC!spId%f6MW!7a29R3YcFgIqFYa^A&$^UBh)tU^9ak zvj@pP-}Io;G>|VhmUyT^6zJ`mT_tnowBuZ?u6ArCX_ zZT!+%MC<#U_Vp#6dz|!1d{T(GG5I$>jqolrr^1;qI- z7uNfwmjHCRlt5uDc!SfYzmNC#_tv?nbJp7j!52@*+v!FIQ(Y~lZf(VBH+GfNGKI!A zcsJZeH#{b*iA&^_H4d3+{ujDQnhXAsu#+I;2nRr zb=oWPg7_~-(sL>w8%czvmSx#!I4OiD%>Q6rqGUqf9*7 zMk>I6-C(xh1Vl>DIgxuonT}v5oyEF3P&cTSq}JEG0wj1r%`6-1n9!u8RgH15%qZGp zX1ySLVuv4FPNh7D$Rhx24s}NrA3hx{rwvmu5#VK8=`=PBQ)j&d%BA$tbHUBMC+FX) zHyT&l6qHAYHL2(R#ehXUzJkV((-qR((VLVyMOrVXO&TnD!xn`j-(<17R1<`>*3|Wp z0tN70@wLjSR;u^?&7n%&XL!?RYPz}@SaOxLc4jSx0;$TYX1@k3sqM@-U6(k(T&npn z*Fk|l`=7aqVb@yZE|-04!UKmyjdh>*M#i6OF`=m2@+UxwqhceHjI9*HrWvJY5m96B z+@%e2%8ISlx9ofIZ%AMq>b^cMV0M~p4Z!T$_Qjd{6oT1KXK%lTI|e5!A8cKa2Z)GF zcP|b4JdU~iA+>cj6U;nW_~CMn=GdRa~E|EOfQs{Bl5ZtHt1p% zQ8jQU`_5MogJ5ZzsvpAq%Jkn6AAVDI=8Zn6whKf?xs(DSvXH@5t;^ zhW%`v`F^R;$l2n~S1smD%P9B>jx$tFY?(nOj98Aq+jj1b=qpl#?&boc;V=)4NELYVO7ReOS~>5D=F3usm+-yt1hy-k$TeO$c3BaI+` zy;2R74#TVRoif>DCy!)p?PBjJDo4R+jQ0$=tBJlK*uxyV)H?Aig;nl!n-aY^3L)G6 zFpp?$?5A`!WwEnLBLa(|1_6uNlsB%D#6g|sGX{WOFCZ6wwW+Q<_3b+w8#Jgn!zYY6xx;bXIyf?27Flo{Skd1 z#E$dZ07kTK)(F!U$o)={YO1qHjT0)kNWzB50Ob4g{u$Uh84n|=(YfgyI5|;2jQ3SA z#9ZXbGI}1v8FMO3(g0MFF*>0NN1&>|1cmOl4`R?k#0h#EoxA#q#`z3JF+UxN>!H!% zD}nw98t6>MBk~^nMwcV3bB)X42ZOqO!R8`<8;q+1*6kXEBiUxl{C}F}6@_jobsW*Q zA_XmWtcI#}ieWP*0(NvfT1;lG8%iK&wYIy@p8)D3sL-r72Y{~Q>=Ra_%={WcG#CPx z2h7MkzJ&$qCQg06s`Lpe#F{3|-{OGwOZeHE8~24OSnLeoW!5Ypm@Z8-#Rbvmn68)k ze>=}W#cQ?BEWom;sOU7rG{5IQg4AhuG13*S0_lPpGi={7wvKi{Oikfr6)5vxK@NDx z?hu%EnU(a#KCiR*z*oK#G6BWgyhFCsz)t}b7(n6AmbHL7whoBk2w!VQD_Xp?4f*JK zDg!UD2KGz!oj_+OTc&!PkzVSADfzDe;TIYNhoq$+L3<9Q|6dR|`-?k(^zsJ-W9mgp ze5;(bTtgviw+%W}R23hArC4ma!!sB*s!Amc&q_i=vMTNqREa_m_|wjjsY_zeQ_#uq ztt40Mcf&9q+-ZXRP)Q2)byd(~HgSyEhY50CS&e+>C1R^cvrIXVVvZK(Q8~4I=>np) zS;9*ndgU78Rf%>^4ku*j7Mz=ckfH{Ca>_Ax4RV>slJ#XM zn4siw%GqJTXvF_UAg3%2SF3^5!`h5QQC9+yXC+j(%J|>mB-7eJLsh3#YL=iKWGCjB zXp6|BCfi;#P(e2JrGw27pl~2$9!DCUg*-h9gdo5<0eLUF48DBP+q26Ox5V;G9i5;I zKt3j8)u=-YWH2XQUF_~wa(Z;N8*cdZrGI00hE_U2?+uLe6+o}w+iMd$mw>?gfz7Zl zkb4$AsdC3hHYqLi0}SB5_R$)}>ZLCyi`5)I9`Z3>FO4B)Sk)OCQX11;vW!6aZpRJ;>=ylt;2oA7Wk!bWBKa^flCpn)z$% z5xv$1b-re>mi^p3Lk`alU$u~9J_}XKV$gLoB+JUoS^c1r`&mMEJPN-VQVhFJq&>Zx z?ZA?qlkw*Zwms%7fY3FKtv8{|%2}$R3!q4V`1v{)y0Ww(3;K@0<+M{t`>`~Lq(-2a zy#6b}atMtqP0MR|75xD-F&X|kB?B7uP-aplr~c|<)UAuo5?vwku*9sZ=a#v{w2@!a zISSb>bU`FH0$8^`-90_oWUSK(Zk@XHy;}4Xse|0(4 z*B0-SiYp*&XBO7~?x`2%6vaTl!$c=vZm{@9O!~X!{mban$eQ_k6YXacAIo5NpisS- zZQFv^TQ-EhpZ_yaJcP#BF0EGrOU!_Hkf9HSuzL|*fyjd*nCNxB{1}UE!DPO>gpTZz zR|0AGRm#wPL=GACw@{JnMttN^jgFIti(e+>UVdGVkWv6UwgNVGggX4`GcP*Yn%Cdh zQUPb0;OKMrw5-;2$d)!n)y8olKXiFtndd9?xfR3ensZ~kwg})z!cTgNNS2#O*%|Mo zH~W&0!5Pn?7-kpw`3H14qL4BIqS6rpf&3U29*P4pQ*!08%W%+Ii`TXAP;F7^fC^C7 z>l--9LnuFhItOf-^l+*!!^b%FmG?rro;Qcv8{p8}D2Iz+Q8FiO~; z)2@F*B1Zui0GoXxS!-40K5TthemU!6^1B*fVL>FGlS0 zp4bFU9nX;b8IR32W;3=nDb~rGL(4<xh zfnHq``84Rbm_yBz*{vnHl?G_$&Wir{s|2VinZGXhxEzp{KC!$H(|}F$Alr&~n4~h4;8Dz5ERj zuIIL1+)3-GgtJ5|k+|rw0t(oU2n6b7Qq#wb>t$_tD*|wByMiNFSNIW8Id`UOteyC|= zXr>ZY*|lZp#^Qwn#d`HTU(g9him&<`@`c`i>`Vkh%`xlw&Z)@Uc=VB@0% zXi0MliXOOxFKa2mAisje^>R@h=}+d*`uV<^)g2G0d6lFlD!$Me!5{K|7HhLxM%THA z=Ms5?q4D#2+l-Jq?1p%fR|(`ll?GIG*`{P5F7{*-+pk(#yfL8WCvfXJpuH+N$`@3E zx6<=hZ~xPM>4b_R(Sr&#(820P$CT)?voPpsx< zH9BpN0D3i0***H?DLuYq0MZwn#~Q*G|3Gv6Ir=`pEn3aikr2A`e9l2!+GhQN0Log! zM8|k4k?~K}BWPFLBhPY@lbaf6$Q|y0k^x(m0cFnB@gwvLVG_O)Nuf*y!f{J-pa-$^ z*A)uGqeRNaSOX4kF9DJ$=J48h$k));jEM|yvn2HiE~vehtR+>Fq+<}x#ar#(Nmsne{&#VuEQ z`y%u@es$0=OY`m0b2jgZ9?NCYMv!*+S}?tVNgt^FYb~LM{sR+Zn4XD`HLYX2hfDn!V2y8Z#)JFB+yyqiKTl zrQ@sf)8p-9&Ro&5a!LS}9>;aG)HjR7z;UsPp|vjTM$Yp%#CA$yYJh=hD}lDvK=zG%*WBDS(&nu7Qb{K-%o#4lt0g-R!+$b zOSa(3<*ap2JGZoO2-GD*UcTF@JdbR?D!OcZIGT+X4o)IUvOLJNHB*$pJSwYbsN0R* zVX^0ijxEeyR4)f8jc^qGCay7k;SF^(>~II!(T<9#&ePx@HPo%C>ks(~LC3_1qNEii z*mSABpxhKZWeB`Vuq-RBt7@!sIZ{85dXMajN}5{MEs&bzX&K3#!bPLqZ<^PCGw^0? zvdDZnBxqVdy!5SHylT(`z5A^%H$Z+Kwp^!N)9y$wROcnSKr6l zY|ZNRHQr0ghB{+#`LDZ_WybZ%S?enCjE3yF*Wxcxg9t_dT~7oz-(|>&hpXphq3x&n zFfErN!`Y96P0MKwsfw!FS$DUBMiHlR+S+AKIGM9m76q5p(jT+i*O>X{n?v*E3 z!M(<6;|CQ`nm;XuqT$0JEOdX~lCb9o4|jhAa6OUiy*XLT>r6K-zPJVd7_kqrw!eSu z;UvXE*S|2#Q4n+JV?}EDN5*YAWDj~rr|x4TZA`v*(*wf{DX`H0QhX=5A|M_sP(6K} zyPlf6a^m;bm(=gH`RIC&D<=XOi5dx$yWQ`#Ik?=v0{~xBch~#>K@P2?WV|*@l=cH&x4SEh5;w?hf`h1a z<{+gY_FYi1{P6z4g=)*W`*Ykv3epP_DQ=haVK+bAo&IePs+upzIMfW6aIw$7H9MEL z6TIU>L7@SnQn4dI|DC)PJcf?enoIzd#+)d;i#C~n9~}= zxzk?@3l5ahlaSGR(ZBbnAL)=wj;tSO+h#y$l;+n+{+$@&pN5&@3Zp0nEe?5dc=08> z>#-FHLzpiG(wS$VWf|wA38HwEtuY~?biYvt9HTz5C-rm_&#S=s!i7CUq`YLS4MyM{m8O)J}uf~!6<6G zDm@{*7XP!x>L0@QRfr#$FwB@dR4czk-Fs{q?w{v`fAKI&!B==UN`?%!(^V7fo0nRe zT8WvWb9YP^B>OJ!S`bhmQa7hc74^?aS;v($Hap-bhYi}Ry%`OmM*I8OsGF(}&W{fZ zoc|`eZx0F${3r-kBC#vvQ_E_Y+s3A!!9SeU`^b8q`ELn1T{XhK5x5HyDig|A%Rr}E zIxTpt6YskH_WujizAb1y9)*_I$0*_)_(0egdEzHj*I2i0^vnI#_0mD*1o@)x!?R-S35!rq8Ce z?Y*Dxr0ZMlkUb#~=T}eeU8^%~x1BlNg$xmGb;}Us#>j3nT=t`g563a)V}aOST=$}U z{@K%=Z|h(Bx$_oim9ZW29?kKeP5?cZigIwPhm~Q-Th2z_DZNPfYV-M*kJGfWY#1zN zdr;Sxm0`zYNjfxj1u@fX>`lOyKzV4e-0PY8&?o)&Z+V?og1!6 zf&NYFkn4?syVe4N+(<4N%Id4x0Bf6eum&YQzztl48I^dY8;DpQCp+;6=@gYc*B@Tj zA=Gu6V4bi}(1n~5Ya^-!_NoQ12eyyXrXs8>h{M?8FIWR8SKA(b3#?C8&bnT8LCH|F zztmF6nhruMX+N`m4BjRr;-~T#{My1f4dJgg{E1yfwaIF6SMfukiL;_T?#T1UmgwYe zDB}9#Q>CxB!J*#)pyfs$| z%r#K1A6+1jGeYO>Vd-U4UBkv%iy?uX8qMWh>tHKqjq0k-+fP)qj-cz|*#fuM+{}3M zUYLgVW*Whf&_|dj2PNyBX zHmBdvBly$4;B2)OgZzk%wIjo77pkCkGt{Bc<+bubyQZ;dwwau!~n$j#i(#$`Z%$G70 zj@TX~a?NBzt@#AXp|4&!_TCU{`=lyY>=M80JS0bA8yHnNiXac$N->+=x!=f(MXuvq zvJb%depW8aFg^JA)XjC7e0OL^Vz<_s_mIS~CPE%w_Q@Q~6U^z8-?R57+pM7#Eg|}s z+Rh$cEvM2GyN7&zyZ`mY8W?oZ6xnDAdj4W*QX#nds)O<#BYNM#ks_X{Nqy+kfpU^! zt9``>+?BK+i92mu2OoY^_rJMv5L&;dp3|wqSa(~ARWXYzTSVM`d*87o39(k*b__qR zKDt+?{(ia09qd}vHDGs_E0g@rZ2zo`mspN54bD1;zsXx|}-ljgqq z5f|CIwXLv-`MTaC#wA4Ij@{s&uiWN=vhBf8CEu%vGiebiF1%DWt2!S)#S~&K_mE?W z(Psu<`M=y$zfLR4E-R{{jP5JMzUhmq8L#mCE!TuRy15};j{GxDjGHW9kai!~U<}$$ zsCmzR_bwb0J(kkm%;h0ElVm$b#-24?Kax{!PW!tWS3B=b-j~dHdo#6LD#MhH)qD%j z#>EBut47|khB*gFFC;9uhV7@pkF{o}=1(Zj6MFjR`F%Mv1MgY?DztBT)WYQ7Cy+*a zF_Rw!7y8O(X^j4HZIzv-=x5HpwY+bjv*5$lfNbOfb}9gx$OzjytGMC9s} zZ{X2^e|S@v*Z7}Epvnb{%jKVAKl$G1F~xs|QTlM=9}v$Ps30IWe?I0E(v1Ik zhs3+uiqw^ofBpMt_d&`_?TT!q3(w|0mvT$gn+&1304;~MURpT~=xDr`F7sLbs1Mlw zFtb{Ey;OVoCfc0b^Gvo#xk*OCZDnyOL$PssW76Z;6r?Ek(~SH5g<#=5vZ4(C5(_VP zj+#$t_eX#K)k#~Vz3`^flUD&abNEufdd`ad^3AwHQ?{BApj&2uU#}51ez}Ueh1*!} zf#Yn>r?!KNMBBY*LaUoE zfkqOwqPx-4_GW*(l$W>XY*Pgn5t$I}>Yt(ZX4_eMZZE~6cU^ma7048&gV}-`aOxL4 zY8pC|+d-|O?aH5uezqs_DUiyNw7(pHIB*=NG8el$E%!pI_d*|GFvY{cbafE(p_ct< z=I2J)!=+Kme4c_eMeUFjLwg5PUysZy1zhE0?g;wnVzmyoC5Q(|n!8X!-Y(7!xWags zbK^w)R^0acC-r2qx%$#H?dUga=94}7)Nxnh4^+Q+Wo^@E=HQrp^7QPS2@8aQrRpOS-T$DTsC8wIC`bpJLo=7Kk?)h~Sr+bA`Ys#Vq9 zkyKq@U9I&tKCa=n-%g)3-*R6Og7l#5o(~DMTZ1iaqhx7%ZJVqj zbublfpj6_GCWwObtNH~Y;1IS^oT(mip<3#xRTr-~$c(wG^T;=8I9-BS3hCAs6>&e? zD0zfR#e4EVX@ND^(l$yxMcHf8k!Jtvjk=jK_`DJzG zJOX&7Nv63tt=P{tE@>x7#@Y9MO=)u}4Ai!?jZb#IiW-*x{T*$i(2c&PZG4qUwP>kS zb&y6moXU5lG3lyiF`Ww8r{-1BD+J8zk|azEJX)zLrEO=L)IB50U!zQOVD@z^Dp>1QK2A)HHN6v<}QT%w(v&ruo7`AZ% z;=pmQ=N+n%25-8*E2q1HL4E)HzWn^0&$+99)27$SH}tiHOZMX(#x_dI`gHQW&4#wI zYN>B;hHdPRv>J0XkE1wj(T9z?ZA>0d{)nyaHPhn0 z*&DWT3F5%9__XS-hz2LKOsd+(sBhK2>3=UQVxQjL6ssi~amTQYy&1NVGi>7qh?Brh zTGR*8gk|iPXzqz{Kcr?E9X|_dAo|2-$V<E{a#VtUb!Q@}w zC0eP_+)Hj&0sY~bkW+EiSz6yZv~84La{c@9Wb(DMp=}iP>kFlN7Wr!%ws9fith&-D zY>vFTksmXNhL0$1|X4uB1 z$O~_q7{`;)%5pD~6RlXu&`8em|3RE#>C(dQ&v}}=oZddZFZU|5XemBncWm1zY3oL9 zs}oz=MyUpm`TDAfVH+1Cx*RuoA;0XqQafGIG?rh=*)nC`hhkkG+cv(QHQ3TNzTPl; z*v92ZrP*lYDEl{M2ckUpEx$zVaC=V5>;A{KjpCg+ic4B#Q`;zwB@R8Q9=34>@}q7W zy~?q2Vl5>jDmrLM6`o3AH#u=|+xR+fK?a-JMscNjqme`xhHYGfa7jeDO)o?8aK;_) zo9xZ2n!Cv3;HOcsUG(6#QFK!rd2M!E+b9hjkN1ie&#;ZFkd0%v5BkPP*B@A7Nx6wS zw~qc?^HZqkVvcSbB{%VYpVn(yW3_+fAJE6KmO2KWbHW8*tT zpUkk0tI^&tvRw<1Cf1*dS!)}l9Oi8)bvk%_+bH`fj%pk?w~eiD&U-4uHg-VXHdF8C zYo^|Wwa)Di;&QCq_x1v|QQkIsP~*6{ZIs8+t!7{+hHdPDq*I0k-&v_d`^Av{-+9@G z+U1H@u#K{>W3#OctJvN)%H8@geU8I6c0qO2Z8u-2c=f}g{SgOvJf?aj_a$tjY#cWn zD_IZt}_CPoGxTZ*qAJ+o*Im zTbYBfQksmy9=1`e%Iy~K!%@ra((nZLu#LUYUvK!>>WTWS6X$jm)i^5}Q#bB2*vUnf#4*@2dg z>ck7#M$H`bbVrL_bIT)&yV*u{@v`GcTKs}#OW*tYw()4||9Pv`V{-jbF{)eb=x_Qu zOfOn}q_1QfwYSIh+6Jq1mraG-&o;_!+0bdM^CcSJrk?1vY@^sQ9&W3ccGp4eXZS=u zDz@%eTB|l?_a&L1X0!HEwlU+7UT+WH<*uyJ?(S(D)xM&2@*tp1tJ>=MwQQr@Tw7)h z`%pG3mv)sqo!h*C>-!AJd$k$A3mV~z*~ZMI;&;v9QT4F2&Qf-@jmmp@EUQe=w=!RE zbzi)eZInuDD2~M5E|2buAz9vP?AZ0Y?rxnbzsnc1+IgdP?cI4S+H&-AwlTM5|96>! z>NANxbL>S)yE@xnyR#yKT%`$5!vt>8$(6muT3F*~aY7yLiw0 z=_8zl?(Uc@tS0xvt$<5v*1_LU>)~9?^7-UT+QyPYy3QiJ=RGXyr@^y4!8YctBT?mj zI(=pb5lj3vFJ~J|>l}GHZV;$N+;P^Ihxaf`wzz{UVbe#f*LdDv)HW9F0dYMm+WP+W zQyR-J-YoVM+enpLoSgh#KIQvr#y_R0)lv<6HQTuA)zr_~?z8FW@gB|v}nbHz+su&a%=V^+ek>itkF~(D>UroY~!jy%+t2}d~dN^@NpINHuKGd zbtt<;R`94?iqd&y+ekIKL5Fi+)e%0=HWI6*gfeVOwSd>NjjNva-R(vTv~-PSZfYZt zey794n&@dB*QqUdZQDrnom1#KQOQ$nBhj^qtk$p>w2f=3@YxU1bQwQB&INrHR!_RU zgS?CSu5QHOMiBS5MpEEq zZKI*0w&5f(2as=_Xer+%*L=A8@s*Mw}=OAhga`W6scLoX{pXZqMP{y$rI V7Gv3e865xs002ovPDHLkV1hF_$2b50 diff --git a/src/ria_toolkit_oss/view/view_signal.py b/src/ria_toolkit_oss/view/view_signal.py index f8d5731..3632ea1 100644 --- a/src/ria_toolkit_oss/view/view_signal.py +++ b/src/ria_toolkit_oss/view/view_signal.py @@ -4,6 +4,7 @@ import textwrap from typing import Optional import matplotlib.pyplot as plt +from matplotlib.patches import Patch import numpy as np from matplotlib import gridspec from PIL import Image @@ -39,6 +40,83 @@ def set_spines(ax, spines): ax.spines["left"].set_visible(False) +def view_annotations( + recording: Recording, + channel: Optional[int] = 0, + output_path: Optional[str] = "images/annotations.png", + title: Optional[str] = "Annotated Spectrogram", + dpi: Optional[int] = 300, + title_fontsize: Optional[int] = 15, + dark: Optional[bool] = True, +) -> None: + # 1. Setup Plotting Environment + plt.close("all") + if dark: + plt.style.use("dark_background") + else: + plt.style.use("default") + + fig, ax = plt.subplots(figsize=(12, 8)) + + complex_signal = recording.data[channel] + sample_rate, center_frequency, _ = extract_metadata_fields(recording.metadata) + annotations = recording.annotations + + # 2. Setup Color Mapping + available_colors = [ + COLORS.get("magenta", "magenta"), + COLORS.get("accent", "cyan"), + COLORS.get("light", "white"), + "lime", + ] + + palette = ["#2196F3", "#9C27B0", "#64B5F6", "#7B1FA2", "#5C6BC0", "#CE93D8", "#1565C0", "#7C4DFF"] + unique_labels = sorted(list(set(ann.label for ann in annotations if ann.label))) + label_to_color = {label: palette[i % len(palette)] for i, label in enumerate(unique_labels)} + + # 3. Generate Spectrogram + Pxx, freqs, times, im = ax.specgram( + complex_signal, NFFT=256, Fs=sample_rate, Fc=center_frequency, noverlap=128, cmap="twilight" + ) + + # 4. Draw Annotations (highest threshold % first so lower % renders on top) + def _threshold_sort_key(ann): + try: + return int(ann.label.rstrip("%")) + except (ValueError, AttributeError): + return 0 + + for annotation in sorted(annotations, key=_threshold_sort_key, reverse=True): + t_start = annotation.sample_start / sample_rate + t_width = annotation.sample_count / sample_rate + f_start = annotation.freq_lower_edge + f_height = annotation.freq_upper_edge - annotation.freq_lower_edge + + ann_color = label_to_color.get(annotation.label, "gray") + + rect = plt.Rectangle( + (t_start, f_start), t_width, f_height, linewidth=1.5, edgecolor=ann_color, facecolor="none", alpha=0.8 + ) + ax.add_patch(rect) + + if unique_labels: + legend_elements = [ + Patch(facecolor=label_to_color[label], alpha=0.3, edgecolor=label_to_color[label], label=label) + for label in unique_labels + ] + ax.legend(handles=legend_elements, loc="upper right", framealpha=0.2) + + ax.set_title(title, fontsize=title_fontsize, pad=20) + ax.set_xlabel("Time (s)", fontsize=12) + ax.set_ylabel("Frequency (MHz)", fontsize=12) + ax.grid(alpha=0.1) + + output_path, _ = set_path(output_path=output_path) + plt.savefig(output_path, dpi=dpi, bbox_inches="tight") + plt.close(fig) + print(f"Professional annotation plot saved to {output_path}") + + def view_channels( recording: Recording, output_path: Optional[str] = "images/signal.png", diff --git a/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py b/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py new file mode 100644 index 0000000..cdfabb5 --- /dev/null +++ b/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py @@ -0,0 +1,820 @@ +"""Annotate command - Automatic detection and manual annotation management.""" + +import json +from pathlib import Path + +import click + +from ria_toolkit_oss.annotations import ( + annotate_with_cusum, + detect_signals_energy, + split_recording_annotations, + threshold_qualifier, +) +from ria_toolkit_oss.datatypes import Annotation +from ria_toolkit_oss.datatypes.recording import Recording +from ria_toolkit_oss.io import load_recording, to_blue, to_npy, to_sigmf, to_wav +from ria_toolkit_oss_cli.ria_toolkit_oss.common import format_frequency, format_sample_count + + +def normalize_sigmf_path(filepath): + """Normalize SigMF path to base name without extension.""" + path = Path(filepath) + + # Handle .sigmf-data, .sigmf-meta, or .sigmf + if ".sigmf" in path.suffix: + # Remove the suffix to get base name + return path.with_suffix("") + else: + return path + + +def detect_input_format(filepath): + """Detect file format from extension.""" + path = Path(filepath) + ext = path.suffix.lower() + + if ext in [".sigmf-data", ".sigmf-meta"]: + return "sigmf" + elif path.name.endswith(".sigmf"): + return "sigmf" + elif ext == ".npy": + return "npy" + elif ext == ".wav": + return "wav" + elif ext == ".blue": + return "blue" + else: + raise click.ClickException(f"Unknown format for '{filepath}'. Supported: .sigmf, .npy, .wav, .blue") + + +def determine_output_path(input_path, output_path, fmt, quiet, overwrite): + input_path = Path(input_path) + input_is_annotated = input_path.stem.endswith("_annotated") + + if output_path: + target = Path(output_path) + elif overwrite and input_is_annotated: + # Write back in-place only when the input is already an _annotated file + target = input_path + else: + target = input_path.with_name(f"{input_path.stem}_annotated{input_path.suffix}") + + if fmt == "sigmf": + final_path = normalize_sigmf_path(target) + if not quiet: + click.echo(f"Saving SigMF metadata to: {final_path}") + else: + final_path = target + if not quiet: + click.echo(f"Saving to: {final_path}") + + # Always allow writing to _annotated files; guard against overwriting originals + target_is_annotated = final_path.stem.endswith("_annotated") + if final_path.exists() and not target_is_annotated and final_path != input_path: + click.echo(f"Error: {final_path} is not an annotated file and cannot be overwritten.", err=True) + return None + + return final_path + + +def save_recording_auto(recording, output_path, input_path, quiet=False, overwrite=False): + """Save recording, auto-detecting format from extension. + + For SigMF: Only overwrites metadata file, data file is unchanged + For other formats: Creates _annotated copy by default, unless overwrite=True + """ + input_path = Path(input_path) + fmt = detect_input_format(input_path) + + # Determine output path + output_path = determine_output_path( + input_path=input_path, output_path=output_path, fmt=fmt, quiet=quiet, overwrite=overwrite + ) + + if fmt == "sigmf": + # Normalize path for SigMF + base_path = output_path + stem = base_path.name + parent = base_path.parent + + # For SigMF: only save metadata, copy data if needed + meta_path = parent / f"{stem}.sigmf-meta" + data_path = parent / f"{stem}.sigmf-data" + + # If output is different from input, copy data file + input_base = normalize_sigmf_path(input_path) + if input_base != base_path: + import shutil + + # Construct input data path correctly + # input_base is like /path/to/recording or /path/to/recording.sigmf + # We need /path/to/recording.sigmf-data + if str(input_base).endswith(".sigmf"): + input_data = Path(str(input_base).replace(".sigmf", ".sigmf-data")) + else: + input_data = input_base.parent / f"{input_base.name}.sigmf-data" + if not quiet: + click.echo(f" Copying: {data_path}") + shutil.copy2(input_data, data_path) + + # Always save metadata (this is the whole point) + to_sigmf(recording, filename=stem, path=parent, overwrite=True) + + if not quiet: + click.echo(f" Updated: {meta_path}") + if input_base != base_path: + click.echo(f" Created: {data_path}") + + elif fmt == "npy": + to_npy(recording, filename=output_path.stem, path=output_path.parent, overwrite=True) + if not quiet: + click.echo(f" Created: {output_path}") + elif fmt == "wav": + to_wav(recording, filename=output_path.stem, path=output_path.parent, overwrite=True) + if not quiet: + click.echo(f" Created: {output_path}") + elif fmt == "blue": + to_blue(recording, filename=output_path.stem, path=output_path.parent, overwrite=True) + if not quiet: + click.echo(f" Created: {output_path}") + + +def determine_frequency_bounds(recording: Recording, freq_lower, freq_upper): + # Handle frequency bounds + if (freq_lower is None) != (freq_upper is None): + raise click.ClickException("Must specify both --freq-lower and --freq-upper, or neither") + + if freq_lower is None: + # Default to full bandwidth + sample_rate = recording.metadata.get("sample_rate", 1) + center_freq = recording.metadata.get("center_frequency", 0) + freq_lower = center_freq - (sample_rate / 2) + freq_upper = center_freq + (sample_rate / 2) + freq_default = True + else: + freq_default = False + if freq_lower >= freq_upper: + raise click.ClickException( + f"Invalid frequency range: lower ({format_frequency(freq_lower)}) " + f"must be < upper ({format_frequency(freq_upper)})" + ) + + return freq_lower, freq_upper, freq_default + + +def get_indices_list(indices, recording: Recording): + if indices: + try: + indices_list = [int(idx.strip()) for idx in indices.split(",")] + # Validate indices + for idx in indices_list: + if idx < 0 or idx >= len(recording.annotations): + raise click.ClickException( + f"Invalid index {idx}. Recording has {len(recording.annotations)} annotation(s)" + ) + except ValueError as e: + raise click.ClickException(f"Invalid indices format. Expected comma-separated integers: {e}") + + return indices_list + else: + return None + + +# ============================================================================ +# Main command group +# ============================================================================ + + +@click.group() +def annotate(): + """Manage and auto-detect annotations on RF recordings. + + \b + MANUAL MANAGEMENT: + list - List all current annotations + add - Manually add a specific annotation + remove - Delete an annotation by its index + clear - Remove all annotations from the recording + + \b + DETECTION & SEPARATION: + energy - Auto-detect using energy-based thresholding + cusum - Auto-detect segments using signal state changes + threshold - Auto-detect samples above magnitude percentage + separate - Auto-detect parallel frequency-offset signals, split into sub-bands + + \b + File Path Handling: + - SigMF files: Pass .sigmf-data, .sigmf-meta, or base name + - Other formats: .npy, .wav, .blue files + + \b + Output Behavior: + - SigMF: Updates .sigmf-meta only (data unchanged), in-place + - Other: Creates _annotated copy unless --overwrite specified + """ + pass + + +# ============================================================================ +# List subcommand +# ============================================================================ + + +@annotate.command() +@click.argument("input", type=click.Path(exists=True)) +@click.option("--verbose", is_flag=True, help="Show detailed annotation info") +def list(input, verbose): + """List all annotations in a recording. + + \b + Examples: + ria annotate list recording.sigmf-data + ria annotate list signal.npy --verbose + """ + try: + recording = load_recording(input) + except Exception as e: + raise click.ClickException(f"Failed to load recording: {e}") + + if len(recording.annotations) == 0: + click.echo(f"No annotations in {Path(input).name}") + return + + click.echo(f"\nAnnotations in {Path(input).name}:") + for i, ann in enumerate(recording.annotations): + # Parse type from comment JSON + try: + comment_data = json.loads(ann.comment) + ann_type = comment_data.get("type", "unknown") + user_comment = comment_data.get("user_comment", "") + except (json.JSONDecodeError, TypeError): + ann_type = "unknown" + user_comment = ann.comment or "" + + # Basic info + freq_range = f"{format_frequency(ann.freq_lower_edge)} - {format_frequency(ann.freq_upper_edge)}" + click.echo( + f" [{i}] Samples {format_sample_count(ann.sample_start)}-" + f"{format_sample_count(ann.sample_start + ann.sample_count)}: {ann.label}" + ) + click.echo(f" Type: {ann_type}") + + if verbose: + if user_comment: + click.echo(f" Comment: {user_comment}") + click.echo(f" Frequency: {freq_range}") + if ann.detail: + click.echo(f" Detail: {ann.detail}") + + click.echo(f"\nTotal: {len(recording.annotations)} annotation(s)") + + +# ============================================================================ +# Add subcommand +# ============================================================================ + + +@annotate.command(context_settings={"max_content_width": 200}) +@click.argument("input", type=click.Path(exists=True)) +@click.option("--start", type=int, required=True, help="Start sample index") +@click.option("--count", type=int, required=True, help="Sample count") +@click.option("--label", type=str, required=True, help="Annotation label") +@click.option("--freq-lower", type=float, help="Lower frequency edge (Hz)") +@click.option("--freq-upper", type=float, help="Upper frequency edge (Hz)") +@click.option("--comment", type=str, help="Human-readable comment") +@click.option( + "--type", + "annotation_type", + type=click.Choice(["standalone", "parallel", "intersection"]), + default="standalone", + help="Annotation type", +) +@click.option("--output", "-o", type=click.Path(), help="Output file path") +@click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") +@click.option("--quiet", is_flag=True, help="Quiet mode") +def add(input, start, count, label, freq_lower, freq_upper, comment, annotation_type, output, overwrite, quiet): + """Add a manual annotation. + + \b + Examples: + ria annotate add file.npy --start 1000 --count 500 --label wifi + ria annotate add signal.sigmf-data --start 0 --count 1000 --label burst --comment "Strong signal" + """ + try: + recording = load_recording(input) + if not quiet: + click.echo(f"Loaded: {input}") + except Exception as e: + raise click.ClickException(f"Failed to load recording: {e}") + + # Validate sample range + n_samples = len(recording.data[0]) + if start < 0: + raise click.ClickException(f"--start must be >= 0, got {start}") + if count <= 0: + raise click.ClickException(f"--count must be > 0, got {count}") + if start + count > n_samples: + raise click.ClickException( + f"Invalid annotation range:\n" + f" Start: {start:,}\n" + f" Count: {count:,}\n" + f" End: {start + count:,}\n" + f"Recording only has {n_samples:,} samples" + ) + + # Handle frequency bounds + freq_lower, freq_upper, freq_default = determine_frequency_bounds( + recording=recording, freq_lower=freq_lower, freq_upper=freq_upper + ) + + # Build comment JSON + comment_data = {"type": annotation_type} + if comment: + comment_data["user_comment"] = comment + + # Create annotation + ann = Annotation( + sample_start=start, + sample_count=count, + freq_lower_edge=freq_lower, + freq_upper_edge=freq_upper, + label=label, + comment=json.dumps(comment_data), + detail={}, + ) + + recording._annotations.append(ann) + + if not quiet: + click.echo("\nAdding annotation:") + click.echo(f" Start: {format_sample_count(start)}") + click.echo(f" Count: {format_sample_count(count)} samples") + freq_str = ( + "full bandwidth" if freq_default else f"{format_frequency(freq_lower)} - {format_frequency(freq_upper)}" + ) + click.echo(f" Frequency: {freq_str}") + click.echo(f" Label: {label}") + click.echo(f" Type: {annotation_type}") + if comment: + click.echo(f" Comment: {comment}") + + try: + save_recording_auto(recording, output, input, quiet, overwrite) + if not quiet: + click.echo(" ✓ Saved") + except Exception as e: + raise click.ClickException(f"Failed to save: {e}") + + +# ============================================================================ +# Remove subcommand +# ============================================================================ + + +@annotate.command(context_settings={"max_content_width": 200}) +@click.argument("input", type=click.Path(exists=True)) +@click.argument("index", type=int) +@click.option("--output", "-o", type=click.Path(), help="Output file path") +@click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") +@click.option("--quiet", is_flag=True, help="Quiet mode") +def remove(input, index, output, overwrite, quiet): + """Remove annotation by index. + + Use 'ria annotate list' to see annotation indices. + + \b + Examples: + ria annotate remove signal.sigmf-data 2 + ria annotate remove file.npy 0 + """ + try: + recording = load_recording(input) + if not quiet: + click.echo(f"Loaded: {input}") + except Exception as e: + raise click.ClickException(f"Failed to load recording: {e}") + + if index < 0 or index >= len(recording.annotations): + raise click.ClickException( + f"Cannot remove annotation at index {index}\n" + f"Recording has {len(recording.annotations)} annotation(s) (indices 0-{len(recording.annotations)-1})" + ) + + removed_ann = recording.annotations[index] + recording._annotations.pop(index) + + if not quiet: + click.echo(f"\nRemoving annotation [{index}]:") + click.echo( + f" Removed: samples {format_sample_count(removed_ann.sample_start)}-" + f"{format_sample_count(removed_ann.sample_start + removed_ann.sample_count)} ({removed_ann.label})" + ) + + try: + save_recording_auto(recording, output_path=input, input_path=input, quiet=quiet, overwrite=True) + if not quiet: + click.echo(" ✓ Saved") + except Exception as e: + raise click.ClickException(f"Failed to save: {e}") + + +# ============================================================================ +# Clear subcommand +# ============================================================================ + + +@annotate.command(context_settings={"max_content_width": 175}) +@click.argument("input", type=click.Path(exists=True)) +@click.option("--output", "-o", type=click.Path(), help="Output file path") +@click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") +@click.option("--force", is_flag=True, help="Skip confirmation") +@click.option("--quiet", is_flag=True, help="Quiet mode") +def clear(input, output, overwrite, force, quiet): + """Clear all annotations. + + \b + Examples: + ria annotate clear signal.sigmf-data + ria annotate clear file.npy --force + """ + try: + recording = load_recording(input) + if not quiet: + click.echo(f"Loaded: {input}") + except Exception as e: + raise click.ClickException(f"Failed to load recording: {e}") + + count_before = len(recording.annotations) + + if count_before == 0: + if not quiet: + click.echo("No annotations to clear") + return + + # Confirm unless --force + if not force and not quiet: + click.echo(f"\nWarning: This will remove all {count_before} annotation(s)") + click.confirm("Continue?", abort=True) + + recording._annotations = [] + + if not quiet: + click.echo(f"\nCleared {count_before} annotation(s)") + + recording._annotations = [] + + try: + save_recording_auto(recording, output_path=input, input_path=input, quiet=quiet, overwrite=True) + if not quiet: + click.echo(" ✓ Saved") + except Exception as e: + raise click.ClickException(f"Failed to save: {e}") + + +# ============================================================================ +# Energy detection subcommand +# ============================================================================ + + +@annotate.command(context_settings={"max_content_width": 200}) +@click.argument("input", type=click.Path(exists=True)) +@click.option("--label", type=str, default="signal", help="Annotation label") +@click.option("--threshold", type=float, default=1.2, help="Threshold multiplier above noise floor") +@click.option("--segments", type=int, default=10, help="Number of segments for noise estimation") +@click.option("--window-size", type=int, default=200, help="Smoothing window size") +@click.option("--min-distance", type=int, default=5000, help="Min distance between detections") +@click.option( + "--freq-method", + type=click.Choice(["nbw", "obw", "full-detected", "full-bandwidth"]), + default="nbw", + help="Frequency bounding method", +) +@click.option("--nfft", type=int, default=None, help="FFT size for frequency calculation") +@click.option("--obw-power", type=float, default=0.99, help="Power percentage for OBW/NBW (0.98-0.9999)") +@click.option( + "--type", + "annotation_type", + type=click.Choice(["standalone", "parallel", "intersection"]), + default="standalone", + help="Annotation type", +) +@click.option("--output", "-o", type=click.Path(), help="Output file path") +@click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") +@click.option("--quiet", is_flag=True, help="Quiet mode") +def energy( + input, + label, + threshold, + segments, + window_size, + min_distance, + freq_method, + nfft, + obw_power, + annotation_type, + output, + overwrite, + quiet, +): + """Auto-detect signals using energy-based method. + + Detects bursts based on energy above noise floor. Best for bursty signals + and intermittent transmissions. + + \b + Frequency Bounding Methods: + nbw - Nominal bandwidth (default, best for real signals) + obw - Occupied bandwidth (more conservative, includes sidelobes) + full-detected - Lowest to highest spectral component + full-bandwidth - Entire Nyquist span + + \b + Examples: + ria annotate energy capture.sigmf-data --label burst + ria annotate energy signal.npy --threshold 1.5 --min-distance 10000 + ria annotate energy signal.sigmf-data --freq-method obw + ria annotate energy signal.sigmf-data --freq-method full-detected + + """ + try: + recording = load_recording(input) + if not quiet: + click.echo(f"Loaded: {input}") + except Exception as e: + raise click.ClickException(f"Failed to load recording: {e}") + + if not quiet: + click.echo("\nDetecting signals using energy-based method...") + click.echo(" Time detection:") + click.echo(f" Segments: {segments}") + click.echo(f" Threshold: {threshold}x noise floor") + click.echo(f" Window size: {window_size} samples") + click.echo(f" Min distance: {min_distance} samples") + click.echo(f" Frequency bounds: {freq_method}") + + try: + initial_count = len(recording.annotations) + recording = detect_signals_energy( + recording, + k=segments, + threshold_factor=threshold, + window_size=window_size, + min_distance=min_distance, + label=label, + annotation_type=annotation_type, + freq_method=freq_method, + nfft=nfft, + obw_power=obw_power, + ) + added = len(recording.annotations) - initial_count + + if not quiet: + click.echo(f" ✓ Added {added} annotation(s)") + + save_recording_auto(recording, output, input, quiet, overwrite) + if not quiet: + click.echo(" ✓ Saved") + except Exception as e: + raise click.ClickException(f"Energy detection failed: {e}") + + +# ============================================================================ +# CUSUM detection subcommand +# ============================================================================ + + +@annotate.command() +@click.argument("input", type=click.Path(exists=True)) +@click.option("--label", type=str, default="segment", help="Annotation label") +@click.option("--min-duration", type=float, default=5.0, help="Min duration in ms (prevents over-segmentation)") +@click.option("--window-size", type=int, default=1, help="Smoothing window size") +@click.option("--tolerance", type=int, default=-1, help="Sample tolerance for merging") +@click.option( + "--type", + "annotation_type", + type=click.Choice(["standalone", "parallel", "intersection"]), + default="standalone", + help="Annotation type", +) +@click.option("--output", "-o", type=click.Path(), help="Output file path") +@click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") +@click.option("--quiet", is_flag=True, help="Quiet mode") +def cusum(input, label, min_duration, window_size, tolerance, annotation_type, output, overwrite, quiet): + """Auto-detect segments using CUSUM method. + + Detects signal state changes (on/off, amplitude transitions). Best for + segmenting continuous signals. + + IMPORTANT: Always specify --min-duration to prevent excessive segmentation. + + \b + Examples: + ria annotate cusum signal.sigmf-data --min-duration 5.0 + ria annotate cusum data.npy --min-duration 10.0 --label state + """ + try: + recording = load_recording(input) + if not quiet: + click.echo(f"Loaded: {input}") + except Exception as e: + raise click.ClickException(f"Failed to load recording: {e}") + + if not quiet: + click.echo("\nDetecting segments using CUSUM...") + click.echo(f" Min duration: {min_duration} ms") + if window_size != 1: + click.echo(f" Window size: {window_size} samples") + + try: + initial_count = len(recording.annotations) + recording = annotate_with_cusum( + recording, + label=label, + window_size=window_size, + min_duration=min_duration, + tolerance=tolerance, + annotation_type=annotation_type, + ) + added = len(recording.annotations) - initial_count + + if not quiet: + click.echo(f" ✓ Added {added} annotation(s)") + + save_recording_auto(recording, output, input, quiet, overwrite) + if not quiet: + click.echo(" ✓ Saved") + except Exception as e: + raise click.ClickException(f"CUSUM detection failed: {e}") + + +# ============================================================================ +# Threshold detection subcommand +# ============================================================================ + + +@annotate.command() +@click.argument("input", type=click.Path(exists=True)) +@click.option("--threshold", type=float, required=True, help="Threshold (0.0-1.0, fraction of max magnitude)") +@click.option("--label", type=str, default=None, help="Annotation label") +@click.option("--window-size", type=int, default=None, help="Smoothing window size in samples (default: 1ms at recording sample rate)") +@click.option( + "--type", + "annotation_type", + type=click.Choice(["standalone", "parallel", "intersection"]), + default="standalone", + help="Annotation type", +) +@click.option("--channel", type=int, default=0, help="Channel index to annotate (default: 0)") +@click.option("--output", "-o", type=click.Path(), help="Output file path") +@click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") +@click.option("--quiet", is_flag=True, help="Quiet mode") +def threshold(input, threshold, label, window_size, annotation_type, channel, output, overwrite, quiet): + """Auto-detect signals using threshold method. + + Detects samples above a percentage of maximum magnitude. Best for simple + power-based detection. + + \b + Examples: + ria annotate threshold signal.sigmf-data --threshold 0.7 --label wifi + ria annotate threshold data.npy --threshold 0.5 --window-size 2048 + """ + if not (0.0 <= threshold <= 1.0): + raise click.ClickException(f"--threshold must be between 0.0 and 1.0, got {threshold}") + + try: + recording = load_recording(input) + if not quiet: + click.echo(f"Loaded: {input}") + except Exception as e: + raise click.ClickException(f"Failed to load recording: {e}") + + if not quiet: + click.echo("\nDetecting signals using threshold qualifier...") + click.echo(f" Threshold: {threshold * 100:.1f}% of max magnitude") + click.echo(f" Window size: {'auto (1ms)' if window_size is None else f'{window_size} samples'}") + click.echo(f" Channel: {channel}") + + try: + initial_count = len(recording.annotations) + recording = threshold_qualifier( + recording, + threshold=threshold, + window_size=window_size, + label=label, + annotation_type=annotation_type, + channel=channel, + ) + added = len(recording.annotations) - initial_count + + if not quiet: + click.echo(f" ✓ Added {added} annotation(s)") + + save_recording_auto(recording, output, input, quiet, overwrite) + if not quiet: + click.echo(" ✓ Saved") + except Exception as e: + raise click.ClickException(f"Threshold detection failed: {e}") + + +# ============================================================================ +# Separate subcommand (Phase 2: Parallel signal separation) +# ============================================================================ + + +@annotate.command() +@click.argument("input", type=click.Path(exists=True)) +@click.option("--indices", type=str, help="Comma-separated annotation indices to split (default: all)") +@click.option("--nfft", type=int, default=65536, help="FFT size for spectral analysis") +@click.option("--noise-threshold-db", type=float, help="Noise floor threshold in dB (auto-estimated if not specified)") +@click.option("--min-component-bw", type=float, default=50e3, help="Min component bandwidth in Hz") +@click.option("--output", "-o", type=click.Path(), help="Output file path") +@click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") +@click.option("--quiet", is_flag=True, help="Quiet mode") +@click.option("--verbose", is_flag=True, help="Verbose output (show detected components)") +def separate(input, indices, nfft, noise_threshold_db, min_component_bw, output, overwrite, quiet, verbose): + """ + Auto-detect parallel frequency-offset signals and split into sub-bands. + + Provides methods to detect and separate overlapping frequency-domain signals + that occupy the same time window but different frequency bands. + + Detects multiple frequency components within single annotations and splits + them into separate annotations. Uses spectral peak detection with dual + bandwidth estimation. + + \b + Key Features: + - Spectral peak detection for frequency components + - Auto noise floor estimation (or user-specified) + - Dual bandwidth estimation: -3dB primary, cumulative power fallback + - Handles narrowband and wide signals (OFDM) + + \b + Examples: + ria annotate separate capture.sigmf-data + ria annotate separate signal.npy --indices 0,1,2 + ria annotate separate data.sigmf-data --noise-threshold-db -70 + ria annotate separate signal.npy --min-component-bw 100000 + + """ + try: + recording = load_recording(input) + if not quiet: + click.echo(f"Loaded: {input}") + except Exception as e: + raise click.ClickException(f"Failed to load recording: {e}") + + # Parse indices if specified + indices_list = get_indices_list(indices=indices, recording=recording) + + if len(recording.annotations) == 0: + if not quiet: + click.echo("No annotations to split") + return + + if not quiet: + click.echo("\nSplitting annotations by frequency components...") + click.echo(f" Input annotations: {len(recording.annotations)}") + if indices_list: + click.echo(f" Splitting indices: {indices_list}") + click.echo(f" FFT size: {nfft}") + if noise_threshold_db is not None: + click.echo(f" Noise threshold: {noise_threshold_db} dB") + else: + click.echo(" Noise threshold: auto-estimated") + click.echo(f" Min component BW: {format_frequency(min_component_bw)}") + + try: + initial_count = len(recording.annotations) + + recording = split_recording_annotations( + recording, + indices=indices_list, + nfft=nfft, + noise_threshold_db=noise_threshold_db, + min_component_bw=min_component_bw, + ) + + final_count = len(recording.annotations) + added = final_count - initial_count + + if not quiet: + click.echo(f" ✓ Output annotations: {final_count} ({'+' if added >= 0 else ''}{added} change)") + if verbose and added > 0: + click.echo("\n Details:") + for i in range(initial_count, final_count): + ann = recording.annotations[i] + freq_range = f"{format_frequency(ann.freq_lower_edge)} - {format_frequency(ann.freq_upper_edge)}" + click.echo( + f" [{i}] samples {format_sample_count(ann.sample_start)}-" + f"{format_sample_count(ann.sample_start + ann.sample_count)}: {freq_range}" + ) + + save_recording_auto(recording, output, input, quiet, overwrite) + if not quiet: + click.echo(" ✓ Saved") + except Exception as e: + raise click.ClickException(f"Spectral separation failed: {e}") diff --git a/src/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py b/src/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py index 60ddba9..e942386 100644 --- a/src/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py +++ b/src/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py @@ -3,6 +3,7 @@ This module contains all the CLI bindings for the ria package. """ +from .annotate import annotate from .capture import capture from .combine import combine from .convert import convert diff --git a/src/ria_toolkit_oss_cli/ria_toolkit_oss/generate.py b/src/ria_toolkit_oss_cli/ria_toolkit_oss/generate.py index f2e14ba..1026b4a 100644 --- a/src/ria_toolkit_oss_cli/ria_toolkit_oss/generate.py +++ b/src/ria_toolkit_oss_cli/ria_toolkit_oss/generate.py @@ -232,8 +232,8 @@ def generate(): \b Examples: - utils synth chirp -b 1e6 -p 0.01 -s 10e6 -o chirp_basic.sigmf - utils synth fsk -M 2 -r 100e3 -s 2e6 -o fsk2_basic.sigmf + ria synth chirp -b 1e6 -p 0.01 -s 10e6 -o chirp_basic.sigmf + ria synth fsk -M 2 -r 100e3 -s 2e6 -o fsk2_basic.sigmf """ pass diff --git a/src/ria_toolkit_oss_cli/ria_toolkit_oss/transform.py b/src/ria_toolkit_oss_cli/ria_toolkit_oss/transform.py index 94524f2..5d6e724 100644 --- a/src/ria_toolkit_oss_cli/ria_toolkit_oss/transform.py +++ b/src/ria_toolkit_oss_cli/ria_toolkit_oss/transform.py @@ -264,13 +264,13 @@ def transform(): Examples:\n \b # List available augmentations - utils transform augment --list + ria transform augment --list \b # Apply channel swap - utils transform augment channel_swap input.npy + ria transform augment channel_swap input.npy \b # Apply AWGN impairment - utils transform impair awgn input.npy --snr-db 15 + ria transform impair awgn input.npy --snr-db 15 """ pass diff --git a/src/ria_toolkit_oss_cli/ria_toolkit_oss/view.py b/src/ria_toolkit_oss_cli/ria_toolkit_oss/view.py index 8e0b51f..9fecec3 100644 --- a/src/ria_toolkit_oss_cli/ria_toolkit_oss/view.py +++ b/src/ria_toolkit_oss_cli/ria_toolkit_oss/view.py @@ -7,7 +7,7 @@ from typing import Optional import click from ria_toolkit_oss.io.recording import from_npy, load_recording -from ria_toolkit_oss.view.view_signal import view_channels, view_sig +from ria_toolkit_oss.view.view_signal import view_annotations, view_channels, view_sig from ria_toolkit_oss.view.view_signal_simple import view_simple_sig from .common import echo_progress, echo_verbose, load_yaml_config @@ -35,6 +35,7 @@ VISUALIZATION_TYPES = { ], }, "channels": {"function": view_channels, "description": "Multi-channel IQ and spectrogram view", "options": []}, + "annotations": {"function": view_annotations, "description": "Annotated spectrogram view", "options": ["channel", "dark"]}, } diff --git a/tests/ria_toolkit_oss_cli/README.md b/tests/ria_toolkit_oss_cli/README.md index 1c4cc8e..3e78415 100644 --- a/tests/ria_toolkit_oss_cli/README.md +++ b/tests/ria_toolkit_oss_cli/README.md @@ -1,6 +1,6 @@ # CLI Tests -Comprehensive test suite for the utils CLI commands. +Comprehensive test suite for the ria CLI commands. ## Test Structure diff --git a/tests/ria_toolkit_oss_cli/__init__.py b/tests/ria_toolkit_oss_cli/__init__.py index 77c8a64..26d94ee 100644 --- a/tests/ria_toolkit_oss_cli/__init__.py +++ b/tests/ria_toolkit_oss_cli/__init__.py @@ -1 +1 @@ -"""Tests for utils CLI commands.""" +"""Tests for ria CLI commands.""" From 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 Mon Sep 17 00:00:00 2001 From: fordg1 Date: Tue, 31 Mar 2026 13:48:38 -0400 Subject: [PATCH 08/43] Removing logos --- .../view/graphics/Qoherent-logo-black-transparent.png | 3 --- .../view/graphics/Qoherent-logo-white-transparent.png | 3 --- 2 files changed, 6 deletions(-) delete mode 100644 src/ria_toolkit_oss/view/graphics/Qoherent-logo-black-transparent.png delete mode 100644 src/ria_toolkit_oss/view/graphics/Qoherent-logo-white-transparent.png diff --git a/src/ria_toolkit_oss/view/graphics/Qoherent-logo-black-transparent.png b/src/ria_toolkit_oss/view/graphics/Qoherent-logo-black-transparent.png deleted file mode 100644 index 807e4ef..0000000 --- a/src/ria_toolkit_oss/view/graphics/Qoherent-logo-black-transparent.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:da9e6d42351b478fc6f3032619689734503f9971340429fa6bdedbdfd96042c2 -size 92294 diff --git a/src/ria_toolkit_oss/view/graphics/Qoherent-logo-white-transparent.png b/src/ria_toolkit_oss/view/graphics/Qoherent-logo-white-transparent.png deleted file mode 100644 index 185600c..0000000 --- a/src/ria_toolkit_oss/view/graphics/Qoherent-logo-white-transparent.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2c8ea31724b66fed24cf58a0a50a0b664028b712506f683a5e04d65419ef2ffa -size 19826 From 5b1c51797ba0602bb9ca16f3132779ca9cf61335 Mon Sep 17 00:00:00 2001 From: fordg1 Date: Tue, 31 Mar 2026 14:54:27 -0400 Subject: [PATCH 09/43] logos --- .../Qoherent-logo-black-transparent.png | Bin 92294 -> 130 bytes .../Qoherent-logo-white-transparent.png | Bin 19826 -> 130 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/src/ria_toolkit_oss/view/graphics/Qoherent-logo-black-transparent.png b/src/ria_toolkit_oss/view/graphics/Qoherent-logo-black-transparent.png index 8da49e8a8d35a0383b86b5d7001463a78eb42178..807e4efe1fb6ae956178725218544c91b14d93e7 100644 GIT binary patch literal 130 zcmWN_!41P83;@7CQ?Nh-7%*|R2^b1eTcSep==9C&q`UHWwEmHG&SPv!J==V|%2;me z8JE=GY8*MK%ZT1sj=F=#jd#k(4y-rPg|#FsR$D+rxKgmrYg>qdu}(XnvSZ}TkqZ;v NkwA?0%aQ=di9f)bCcgjx literal 92294 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 z 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 >x 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 diff --git a/src/ria_toolkit_oss/view/graphics/Qoherent-logo-white-transparent.png b/src/ria_toolkit_oss/view/graphics/Qoherent-logo-white-transparent.png index a18426e743c09368b889c16eb3b72dd30ea8056f..185600c26a5c15b58d9479110ab44ef9308e4c93 100644 GIT binary patch literal 130 zcmWN_K@Ni;5CFhCuiyg}SYbtfv%qd4X^Sc9p|7u}N#^43X#Jz>aqMF==Gl(NOU86t z&ot%!He=^(E~k1cdQ^gd8|26|y9Ing6fWSigh-ZxC3}~*5YyrySQLyi1-8Cm@NQ)R N5mKZ5Si3a>sz2I3CoKQ~ literal 19826 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 V7Gv3e865xs002ovPDHLkV1hF_$2b50 From 5cfced8855414e54d9e2d10733dce1bb71d04d29 Mon Sep 17 00:00:00 2001 From: fordg1 Date: Tue, 31 Mar 2026 15:16:32 -0400 Subject: [PATCH 10/43] Fix merge conflicts and port all imports from utils to ria_toolkit_oss Resolves unresolved merge conflict markers left in committed files across the annotations, view, data, and CLI packages. Updates all remaining imports from the old utils.* namespace to ria_toolkit_oss.datatypes, ria_toolkit_oss.io, and ria_toolkit_oss.view equivalents. --- src/ria_toolkit_oss/annotations/__init__.py | 7 - .../annotations/annotation_transforms.py | 4 - .../annotations/cusum_annotator.py | 8 -- .../annotations/energy_detector.py | 15 +-- .../annotations/parallel_signal_separator.py | 23 ---- .../annotations/qualify_slice.py | 4 - .../annotations/signal_isolation.py | 5 - .../annotations/threshold_qualifier.py | 121 ------------------ src/ria_toolkit_oss/data/recording.py | 28 ++-- .../frequency_translation/upconversion.py | 4 +- src/ria_toolkit_oss/view/recording.py | 8 +- src/ria_toolkit_oss/view/view_signal.py | 33 +---- .../view/view_signal_simple.py | 4 +- .../ria_toolkit_oss/annotate.py | 86 ------------- .../ria_toolkit_oss/commands.py | 4 +- 15 files changed, 26 insertions(+), 328 deletions(-) diff --git a/src/ria_toolkit_oss/annotations/__init__.py b/src/ria_toolkit_oss/annotations/__init__.py index d2d5806..ebb3d10 100644 --- a/src/ria_toolkit_oss/annotations/__init__.py +++ b/src/ria_toolkit_oss/annotations/__init__.py @@ -1,4 +1,3 @@ -<<<<<<< HEAD """ The annotations package contains tools and utilities for creating, managing, and processing annotations. @@ -54,9 +53,3 @@ from .parallel_signal_separator import ( from .qualify_slice import qualify_slice_from_annotations from .signal_isolation import isolate_signal from .threshold_qualifier import threshold_qualifier -======= -from .cusum_annotator import annotate_with_cusum -from .energy_detector import detect_signals_energy -from .parallel_signal_separator import split_recording_annotations -from .threshold_qualifier import threshold_qualifier ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 diff --git a/src/ria_toolkit_oss/annotations/annotation_transforms.py b/src/ria_toolkit_oss/annotations/annotation_transforms.py index d91e13d..47300c1 100644 --- a/src/ria_toolkit_oss/annotations/annotation_transforms.py +++ b/src/ria_toolkit_oss/annotations/annotation_transforms.py @@ -1,8 +1,4 @@ -<<<<<<< HEAD -from utils.data.annotation import Annotation -======= from ria_toolkit_oss.datatypes.annotation import Annotation ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 # TODO figure out how to transfer labels in the merge case diff --git a/src/ria_toolkit_oss/annotations/cusum_annotator.py b/src/ria_toolkit_oss/annotations/cusum_annotator.py index 9837e07..d37186c 100644 --- a/src/ria_toolkit_oss/annotations/cusum_annotator.py +++ b/src/ria_toolkit_oss/annotations/cusum_annotator.py @@ -3,11 +3,7 @@ from typing import Optional import numpy as np -<<<<<<< HEAD -from utils.data import Annotation, Recording -======= from ria_toolkit_oss.datatypes import Annotation, Recording ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 def annotate_with_cusum( @@ -28,11 +24,7 @@ def annotate_with_cusum( changes between a low and high amplitude. :param recording: A ``Recording`` object to annotate. -<<<<<<< HEAD - :type recording: ``utils.data.Recording`` -======= :type recording: ``ria_toolkit_oss.datatypes.Recording`` ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 :param label: Label for the detected segments. :type label: str :param window_size: The length (in samples) of the moving average window. diff --git a/src/ria_toolkit_oss/annotations/energy_detector.py b/src/ria_toolkit_oss/annotations/energy_detector.py index 9706532..109fe6e 100644 --- a/src/ria_toolkit_oss/annotations/energy_detector.py +++ b/src/ria_toolkit_oss/annotations/energy_detector.py @@ -11,11 +11,7 @@ from typing import Tuple import numpy as np from scipy.signal import filtfilt -<<<<<<< HEAD -from utils.data import Annotation, Recording -======= from ria_toolkit_oss.datatypes import Annotation, Recording ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 def detect_signals_energy( @@ -77,13 +73,8 @@ def detect_signals_energy( **Example**:: -<<<<<<< HEAD - >>> from utils.io import load_recording - >>> from utils.annotations import detect_signals_energy -======= >>> from ria.io import load_recording >>> from ria_toolkit_oss.annotations import detect_signals_energy ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 >>> recording = load_recording("capture.sigmf") >>> # Detect with NBW frequency bounds (default, best for real signals) @@ -239,7 +230,7 @@ def calculate_nominal_bandwidth( **Example**:: - >>> from utils.annotations import calculate_nominal_bandwidth + >>> from ria_toolkit_oss.annotations import calculate_nominal_bandwidth >>> nbw, center = calculate_nominal_bandwidth(signal, sampling_rate=10e6) >>> print(f"NBW: {nbw/1e6:.3f} MHz, Center: {center/1e6:.3f} MHz") """ @@ -356,11 +347,7 @@ def annotate_with_obw( **Example**:: -<<<<<<< HEAD - >>> from utils.annotations import annotate_with_obw -======= >>> from ria_toolkit_oss.annotations import annotate_with_obw ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 >>> annotated = annotate_with_obw(recording, label="signal_obw") """ signal = recording.data[0] diff --git a/src/ria_toolkit_oss/annotations/parallel_signal_separator.py b/src/ria_toolkit_oss/annotations/parallel_signal_separator.py index 6a412ed..957cf58 100644 --- a/src/ria_toolkit_oss/annotations/parallel_signal_separator.py +++ b/src/ria_toolkit_oss/annotations/parallel_signal_separator.py @@ -38,11 +38,7 @@ sub-annotations. Example: Two WiFi channels captured simultaneously: -<<<<<<< HEAD - >>> from utils.annotations import find_spectral_components -======= >>> from ria_toolkit_oss.annotations import find_spectral_components ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 >>> # Detect the two distinct channels (returns relative frequencies) >>> components = find_spectral_components(signal, sampling_rate=20e6) >>> print(f"Found {len(components)} components") @@ -59,11 +55,7 @@ import numpy as np from scipy import ndimage from scipy import signal as scipy_signal -<<<<<<< HEAD -from utils.data import Annotation, Recording -======= from ria_toolkit_oss.datatypes import Annotation, Recording ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 def find_spectral_components( @@ -119,13 +111,8 @@ def find_spectral_components( **Example**:: -<<<<<<< HEAD - >>> from utils.io import load_recording - >>> from utils.annotations import find_spectral_components -======= >>> from ria.io import load_recording >>> from ria_toolkit_oss.annotations import find_spectral_components ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 >>> recording = load_recording("capture.sigmf") >>> segment = recording.data[0][start:end] >>> # Components in relative (baseband) frequency @@ -254,13 +241,8 @@ def split_annotation_by_components( **Example**:: -<<<<<<< HEAD - >>> from utils.io import load_recording - >>> from utils.annotations import split_annotation_by_components -======= >>> from ria.io import load_recording >>> from ria_toolkit_oss.annotations import split_annotation_by_components ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 >>> recording = load_recording("capture.sigmf") >>> # Original annotation spans multiple channels >>> original = recording.annotations[0] @@ -387,13 +369,8 @@ def split_recording_annotations( **Example**:: -<<<<<<< HEAD - >>> from utils.io import load_recording - >>> from utils.annotations import split_recording_annotations -======= >>> from ria.io import load_recording >>> from ria_toolkit_oss.annotations import split_recording_annotations ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 >>> recording = load_recording("capture.sigmf") >>> # Split all annotations >>> split_rec = split_recording_annotations(recording) diff --git a/src/ria_toolkit_oss/annotations/qualify_slice.py b/src/ria_toolkit_oss/annotations/qualify_slice.py index 08d590b..2336fe5 100644 --- a/src/ria_toolkit_oss/annotations/qualify_slice.py +++ b/src/ria_toolkit_oss/annotations/qualify_slice.py @@ -1,10 +1,6 @@ import numpy as np -<<<<<<< HEAD -from utils.data import Recording -======= from ria_toolkit_oss.datatypes import Recording ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 def qualify_slice_from_annotations(recording: Recording, slice_length: int): diff --git a/src/ria_toolkit_oss/annotations/signal_isolation.py b/src/ria_toolkit_oss/annotations/signal_isolation.py index 255b95b..47852ae 100644 --- a/src/ria_toolkit_oss/annotations/signal_isolation.py +++ b/src/ria_toolkit_oss/annotations/signal_isolation.py @@ -1,13 +1,8 @@ import numpy as np from scipy.signal import butter, lfilter -<<<<<<< HEAD -from utils.data.annotation import Annotation -from utils.data.recording import Recording -======= from ria_toolkit_oss.datatypes.annotation import Annotation from ria_toolkit_oss.datatypes.recording import Recording ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 def isolate_signal(recording: Recording, annotation: Annotation) -> Recording: diff --git a/src/ria_toolkit_oss/annotations/threshold_qualifier.py b/src/ria_toolkit_oss/annotations/threshold_qualifier.py index 7efb541..338f13c 100644 --- a/src/ria_toolkit_oss/annotations/threshold_qualifier.py +++ b/src/ria_toolkit_oss/annotations/threshold_qualifier.py @@ -46,29 +46,17 @@ from typing import Optional import numpy as np -<<<<<<< HEAD -from utils.data import Annotation, Recording - - -def _find_ranges(indices, window_size): -======= from ria_toolkit_oss.datatypes import Annotation, Recording def _find_ranges(indices, max_gap): ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 """ Groups individual indices into continuous temporal ranges. Args: indices: Array of indices where the signal exceeded a threshold. -<<<<<<< HEAD - window_size: Maximum gap allowed between indices to consider them part - of the same range. -======= max_gap: Maximum gap allowed between indices to consider them part of the same range. ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 Returns: A list of (start, stop) tuples representing detected signal segments. @@ -77,30 +65,6 @@ def _find_ranges(indices, max_gap): if len(indices) == 0: return [] -<<<<<<< HEAD - ranges = [] - - start = indices[0] - in_range = False - - for i in range(1, len(indices)): - # If the gap between current and previous index is within window_size, - # keep the range alive. - if indices[i] - indices[i - 1] <= window_size: - if not in_range: - # Start a new range - start = indices[i - 1] - in_range = True - else: - # Gap is too large; close the current range if one was active. - if in_range: - ranges.append((start, indices[i - 1])) - in_range = False - - # Ensure the final segment is captured if the loop ends while in_range. - if in_range: - ranges.append((start, indices[-1])) -======= start = indices[0] prev = indices[0] ranges = [] @@ -112,19 +76,10 @@ def _find_ranges(indices, max_gap): prev = indices[i] ranges.append((start, prev)) ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 return ranges -<<<<<<< HEAD -def threshold_qualifier( - recording: Recording, - threshold: float, - window_size: Optional[int] = 1024, - label: Optional[str] = None, - annotation_type: Optional[str] = "standalone", -======= def _expand_and_filter_ranges( smoothed_power: np.ndarray, initial_ranges: list[tuple[int, int]], @@ -231,7 +186,6 @@ def threshold_qualifier( label: Optional[str] = None, annotation_type: Optional[str] = "standalone", channel: int = 0, ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 ) -> Recording: """ Annotate a recording with bounding boxes for regions above a threshold. @@ -249,27 +203,15 @@ def threshold_qualifier( Args: recording: The Recording object containing IQ or real signal data. threshold: Sensitivity multiplier (0.0 to 1.0) applied to max power. -<<<<<<< HEAD - window_size: Size of the smoothing filter and max gap for merging hits. - label: Custom string label for annotations. - annotation_type: Metadata string for the 'type' field in the annotation. -======= window_size: Size of the smoothing filter in samples. Defaults to 1ms worth of samples. label: Custom string label for annotations. annotation_type: Metadata string for the 'type' field in the annotation. channel: Index of the channel to annotate. Defaults to 0. ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 Returns: A new Recording object populated with detected Annotations. """ # Extract signal and metadata -<<<<<<< HEAD - sample_data = recording.data[0] - sample_rate = recording.metadata["sample_rate"] - center_frequency = recording.metadata.get("center_frequency", 0) - -======= sample_data = recording.data[channel] sample_rate = recording.metadata["sample_rate"] center_frequency = recording.metadata.get("center_frequency", 0) @@ -277,69 +219,11 @@ def threshold_qualifier( if window_size is None: window_size = max(64, int(sample_rate * 0.001)) ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 # --- 1. SIGNAL CONDITIONING --- # Convert to power (Magnitude squared) power_data = np.abs(sample_data) ** 2 smoothing_window = np.ones(window_size) / window_size smoothed_power = np.convolve(power_data, smoothing_window, mode="same") -<<<<<<< HEAD - - # Define thresholds based on the global peak of the smoothed signal - max_power = np.max(smoothed_power) - trigger_val = threshold * max_power # High threshold to trigger detection - boundary_val = (threshold / 2) * max_power # Low threshold to define signal edges - - # --- 2. INITIAL DETECTION --- - # Identify indices that strictly exceed the high trigger - indices = np.where(smoothed_power > trigger_val)[0] - initial_ranges = _find_ranges(indices=indices, window_size=window_size) - - annotations = [] - - threshold_base = min(sample_rate, len(sample_data)) - - for start, stop in initial_ranges: - if (stop - start) < (threshold_base * 0.01): - continue - - # --- 3. HYSTERESIS (Boundary Expansion) --- - # Search backward from 'start' until power drops below the low boundary_val - true_start = start - while true_start > 0 and smoothed_power[true_start] > boundary_val: - true_start -= 1 - - # Search forward from 'stop' until power drops below the low boundary_val - true_stop = stop - while true_stop < len(smoothed_power) - 1 and smoothed_power[true_stop] > boundary_val: - true_stop += 1 - - # --- 4. SPECTRAL ANALYSIS (Frequency Detection) --- - signal_segment = sample_data[true_start:true_stop] - if len(signal_segment) > 0: - fft_data = np.abs(np.fft.fftshift(np.fft.fft(signal_segment))) - fft_freqs = np.fft.fftshift(np.fft.fftfreq(len(signal_segment), 1 / sample_rate)) - - # Determine frequency bounds where spectral energy is > 15% of segment peak - spectral_thresh = np.max(fft_data) * 0.15 - sig_indices = np.where(fft_data > spectral_thresh)[0] - - # Ensure the signal has some spectral width before annotating - if len(sig_indices) < 5: - continue - - if len(sig_indices) > 0: - f_min, f_max = fft_freqs[sig_indices[0]], fft_freqs[sig_indices[-1]] - else: - # Default to middle half of bandwidth if no clear peaks found - f_min, f_max = -sample_rate / 4, sample_rate / 4 - else: - f_min, f_max = -sample_rate / 4, sample_rate / 4 - - # --- 5. ANNOTATION GENERATION --- - if label is None: - label = f"{int(threshold*100)}%" -======= group_gap_samples = _estimate_group_gap(sample_rate) # Define thresholds using peak relative to baseline. @@ -442,7 +326,6 @@ def threshold_qualifier( # --- 5. ANNOTATION GENERATION --- ann_label = label if label is not None else f"{int(threshold*100)}%" ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 # Pack metadata for the UI/Downstream processing comment_data = { @@ -459,11 +342,7 @@ def threshold_qualifier( sample_count=true_stop - true_start, freq_lower_edge=center_frequency + f_min, freq_upper_edge=center_frequency + f_max, -<<<<<<< HEAD - label=label, -======= label=ann_label, ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 comment=json.dumps(comment_data), detail={"generator": "hysteresis_qualifier"}, ) diff --git a/src/ria_toolkit_oss/data/recording.py b/src/ria_toolkit_oss/data/recording.py index 50e03ee..20939bd 100644 --- a/src/ria_toolkit_oss/data/recording.py +++ b/src/ria_toolkit_oss/data/recording.py @@ -12,7 +12,7 @@ from typing import Any, Iterator, Optional import numpy as np from numpy.typing import ArrayLike -from utils.data.annotation import Annotation +from ria_toolkit_oss.datatypes.annotation import Annotation PROTECTED_KEYS = ["rec_id", "timestamp"] @@ -66,7 +66,7 @@ class Recording: **Examples:** >>> import numpy - >>> from utils.data import Recording, Annotation + >>> from ria_toolkit_oss.datatypes import Recording, Annotation >>> # Create an array of complex samples, just 1s in this case. >>> samples = numpy.ones(10000, dtype=numpy.complex64) @@ -305,7 +305,7 @@ class Recording: Create a recording and add metadata: >>> import numpy - >>> from utils.data import Recording + >>> from ria_toolkit_oss.datatypes import Recording >>> >>> samples = numpy.ones(10000, dtype=numpy.complex64) >>> metadata = { @@ -360,7 +360,7 @@ class Recording: Create a recording and update metadata: >>> import numpy - >>> from utils.data import Recording + >>> from ria_toolkit_oss.datatypes import Recording >>> samples = numpy.ones(10000, dtype=numpy.complex64) >>> metadata = { @@ -414,7 +414,7 @@ class Recording: Create a recording and add metadata: >>> import numpy - >>> from utils.data import Recording + >>> from ria_toolkit_oss.datatypes import Recording >>> samples = numpy.ones(10000, dtype=numpy.complex64) >>> metadata = { @@ -455,7 +455,7 @@ class Recording: Create a recording and view it as a plot in a .png image: >>> import numpy - >>> from utils.data import Recording + >>> from ria_toolkit_oss.datatypes import Recording >>> samples = numpy.ones(10000, dtype=numpy.complex64) >>> metadata = { @@ -466,14 +466,14 @@ class Recording: >>> recording = Recording(data=samples, metadata=metadata) >>> recording.view() """ - from utils.view import view_sig + from ria_toolkit_oss.view import view_sig view_sig(recording=self, output_path=output_path, **kwargs) def simple_view(self, **kwargs) -> None: """Create a plot of various signal visualizations as a PNG or SVG image. - :param kwargs: Keyword arguments passed on to utils.view.view_signal_simple.create_plots. + :param kwargs: Keyword arguments passed on to ria_toolkit_oss.view.view_signal_simple.create_plots. :type: dict of keyword arguments **Examples:** @@ -481,7 +481,7 @@ class Recording: Create a recording and view it as a plot in a .png image: >>> import numpy - >>> from utils.data import Recording + >>> from ria_toolkit_oss.datatypes import Recording >>> samples = numpy.ones(10000, dtype=numpy.complex64) >>> metadata = { @@ -492,7 +492,7 @@ class Recording: >>> recording = Recording(data=samples, metadata=metadata) >>> recording.simple_view() """ - from utils.view.view_signal_simple import view_simple_sig + from ria_toolkit_oss.view.view_signal_simple import view_simple_sig view_simple_sig(recording=self, **kwargs) @@ -530,7 +530,7 @@ class Recording: >>> recording = Recording(data=samples, metadata=metadata) >>> recording.view() """ - from utils.io.recording import to_sigmf + from ria_toolkit_oss.io.recording import to_sigmf to_sigmf(filename=filename, path=path, recording=self, overwrite=overwrite) @@ -565,7 +565,7 @@ class Recording: >>> recording = Recording(data=samples, metadata=metadata) >>> recording.to_npy() """ - from utils.io.recording import to_npy + from ria_toolkit_oss.io.recording import to_npy to_npy(recording=self, filename=filename, path=path, overwrite=overwrite) @@ -611,7 +611,7 @@ class Recording: >>> recording = Recording(data=samples, metadata=metadata) >>> recording.to_wav() """ - from utils.io.recording import to_wav + from ria_toolkit_oss.io.recording import to_wav return to_wav( recording=self, @@ -661,7 +661,7 @@ class Recording: >>> recording = Recording(data=samples, metadata=metadata) >>> recording.to_blue() """ - from utils.io.recording import to_blue + from ria_toolkit_oss.io.recording import to_blue return to_blue(recording=self, filename=filename, path=path, data_format=data_format, overwrite=overwrite) diff --git a/src/ria_toolkit_oss/signal/block_generator/frequency_translation/upconversion.py b/src/ria_toolkit_oss/signal/block_generator/frequency_translation/upconversion.py index 18f5464..c0272c3 100644 --- a/src/ria_toolkit_oss/signal/block_generator/frequency_translation/upconversion.py +++ b/src/ria_toolkit_oss/signal/block_generator/frequency_translation/upconversion.py @@ -1,6 +1,6 @@ import numpy as np -from utils.signal.block_generator.block import Block -from utils.signal.block_generator.data_types import DataType +from ria_toolkit_oss.signal.block_generator.block import Block +from ria_toolkit_oss.signal.block_generator.data_types import DataType class FrequencyUpConversion(Block): diff --git a/src/ria_toolkit_oss/view/recording.py b/src/ria_toolkit_oss/view/recording.py index 381f07e..b9c413b 100644 --- a/src/ria_toolkit_oss/view/recording.py +++ b/src/ria_toolkit_oss/view/recording.py @@ -11,7 +11,7 @@ def spectrogram(rec: Recording, thumbnail: bool = False) -> Figure: """Create a spectrogram for the recording. :param rec: Signal to plot. - :type rec: utils.data.Recording + :type rec: ria_toolkit_oss.datatypes.Recording :param thumbnail: Whether to return a small thumbnail version or full plot. :type thumbnail: bool @@ -95,7 +95,7 @@ def iq_time_series(rec: Recording) -> Figure: """Create a time series plot of the real and imaginary parts of signal. :param rec: Signal to plot. - :type rec: utils.data.Recording + :type rec: ria_toolkit_oss.datatypes.Recording :return: Time series plot as a Plotly figure. """ @@ -125,7 +125,7 @@ def frequency_spectrum(rec: Recording) -> Figure: """Create a frequency spectrum plot from the recording. :param rec: Input signal to plot. - :type rec: utils.data.Recording + :type rec: ria_toolkit_oss.datatypes.Recording :return: Frequency spectrum as a Plotly figure. """ @@ -160,7 +160,7 @@ def constellation(rec: Recording) -> Figure: """Create a constellation plot from the recording. :param rec: Input signal to plot. - :type rec: utils.data.Recording + :type rec: ria_toolkit_oss.datatypes.Recording :return: Constellation as a Plotly figure. """ diff --git a/src/ria_toolkit_oss/view/view_signal.py b/src/ria_toolkit_oss/view/view_signal.py index 38b6056..86cfbe7 100644 --- a/src/ria_toolkit_oss/view/view_signal.py +++ b/src/ria_toolkit_oss/view/view_signal.py @@ -13,8 +13,8 @@ from scipy.fft import fft, fftshift from scipy.signal import spectrogram from scipy.signal.windows import hann -from utils.data.recording import Recording -from utils.view.tools import COLORS, decimate, extract_metadata_fields, set_path +from ria_toolkit_oss.datatypes.recording import Recording +from ria_toolkit_oss.view.tools import COLORS, decimate, extract_metadata_fields, set_path def get_fft_size(plot_length): @@ -58,17 +58,6 @@ def view_annotations( sample_rate, center_frequency, _ = extract_metadata_fields(recording.metadata) annotations = recording.annotations -<<<<<<< HEAD - # 2. Setup Color Mapping (No more hardcoded yellow fallback!) - # available_colors = [ - # COLORS.get("magenta", "magenta"), - # COLORS.get("accent", "cyan"), - # COLORS.get("light", "white"), - # "lime", - # ] - - palette = ["#FF00FF", "#00FF00", "#00FFFF", "#FFFF00", "#FF8000"] -======= # 2. Setup Color Mapping available_colors = [ COLORS.get("magenta", "magenta"), @@ -78,7 +67,6 @@ def view_annotations( ] palette = ["#2196F3", "#9C27B0", "#64B5F6", "#7B1FA2", "#5C6BC0", "#CE93D8", "#1565C0", "#7C4DFF"] ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 unique_labels = sorted(list(set(ann.label for ann in annotations if ann.label))) label_to_color = {label: palette[i % len(palette)] for i, label in enumerate(unique_labels)} @@ -87,11 +75,6 @@ def view_annotations( complex_signal, NFFT=256, Fs=sample_rate, Fc=center_frequency, noverlap=128, cmap="twilight" ) -<<<<<<< HEAD - # 4. Draw Annotations - for annotation in annotations: - # --- DEFINING VARIABLES FIRST --- -======= # 4. Draw Annotations (highest threshold % first so lower % renders on top) def _threshold_sort_key(ann): try: @@ -100,21 +83,13 @@ def view_annotations( return 0 for annotation in sorted(annotations, key=_threshold_sort_key, reverse=True): ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 t_start = annotation.sample_start / sample_rate t_width = annotation.sample_count / sample_rate f_start = annotation.freq_lower_edge f_height = annotation.freq_upper_edge - annotation.freq_lower_edge -<<<<<<< HEAD - # Look up the color for this specific label ann_color = label_to_color.get(annotation.label, "gray") - # Draw the Rectangle -======= - ann_color = label_to_color.get(annotation.label, "gray") - ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 rect = plt.Rectangle( (t_start, f_start), t_width, f_height, linewidth=1.5, edgecolor=ann_color, facecolor="none", alpha=0.8 ) @@ -130,11 +105,7 @@ def view_annotations( ax.set_title(title, fontsize=title_fontsize, pad=20) ax.set_xlabel("Time (s)", fontsize=12) ax.set_ylabel("Frequency (MHz)", fontsize=12) -<<<<<<< HEAD - ax.grid(alpha=0.1) # Add faint grid -======= ax.grid(alpha=0.1) ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 output_path, _ = set_path(output_path=output_path) plt.savefig(output_path, dpi=dpi, bbox_inches="tight") diff --git a/src/ria_toolkit_oss/view/view_signal_simple.py b/src/ria_toolkit_oss/view/view_signal_simple.py index 248486f..d97452e 100644 --- a/src/ria_toolkit_oss/view/view_signal_simple.py +++ b/src/ria_toolkit_oss/view/view_signal_simple.py @@ -12,8 +12,8 @@ import numpy as np from scipy.fft import fft, fftshift from scipy.signal.windows import hann -from utils.data.recording import Recording -from utils.view.tools import COLORS, decimate, extract_metadata_fields, set_path +from ria_toolkit_oss.datatypes.recording import Recording +from ria_toolkit_oss.view.tools import COLORS, decimate, extract_metadata_fields, set_path def _add_annotations(annotations, compact_mode, show_labels, sample_rate_hz, center_freq_hz, ax2): diff --git a/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py b/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py index 1bbad66..cdfabb5 100644 --- a/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py +++ b/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py @@ -11,13 +11,8 @@ from ria_toolkit_oss.annotations import ( split_recording_annotations, threshold_qualifier, ) -<<<<<<< HEAD -from ria_toolkit_oss.data import Annotation -from ria_toolkit_oss.data.recording import Recording -======= from ria_toolkit_oss.datatypes import Annotation from ria_toolkit_oss.datatypes.recording import Recording ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 from ria_toolkit_oss.io import load_recording, to_blue, to_npy, to_sigmf, to_wav from ria_toolkit_oss_cli.ria_toolkit_oss.common import format_frequency, format_sample_count @@ -55,15 +50,6 @@ def detect_input_format(filepath): def determine_output_path(input_path, output_path, fmt, quiet, overwrite): input_path = Path(input_path) -<<<<<<< HEAD - - if output_path: - target = Path(output_path) - final_path = target - else: - annotated_name = f"{input_path.stem}_annotated" - target = input_path.with_name(f"{annotated_name}{input_path.suffix}") -======= input_is_annotated = input_path.stem.endswith("_annotated") if output_path: @@ -73,7 +59,6 @@ def determine_output_path(input_path, output_path, fmt, quiet, overwrite): target = input_path else: target = input_path.with_name(f"{input_path.stem}_annotated{input_path.suffix}") ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 if fmt == "sigmf": final_path = normalize_sigmf_path(target) @@ -84,15 +69,10 @@ def determine_output_path(input_path, output_path, fmt, quiet, overwrite): if not quiet: click.echo(f"Saving to: {final_path}") -<<<<<<< HEAD - if final_path.exists() and not overwrite and final_path != input_path: - click.echo(f"Error: {final_path} already exists. Use --overwrite to replace it.", err=True) -======= # Always allow writing to _annotated files; guard against overwriting originals target_is_annotated = final_path.stem.endswith("_annotated") if final_path.exists() and not target_is_annotated and final_path != input_path: click.echo(f"Error: {final_path} is not an annotated file and cannot be overwritten.", err=True) ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 return None return final_path @@ -250,13 +230,8 @@ def list(input, verbose): \b Examples: -<<<<<<< HEAD - utils annotate list recording.sigmf-data - utils annotate list signal.npy --verbose -======= ria annotate list recording.sigmf-data ria annotate list signal.npy --verbose ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 """ try: recording = load_recording(input) @@ -324,13 +299,8 @@ def add(input, start, count, label, freq_lower, freq_upper, comment, annotation_ \b Examples: -<<<<<<< HEAD - utils annotate add file.npy --start 1000 --count 500 --label wifi - utils annotate add signal.sigmf-data --start 0 --count 1000 --label burst --comment "Strong signal" -======= ria annotate add file.npy --start 1000 --count 500 --label wifi ria annotate add signal.sigmf-data --start 0 --count 1000 --label burst --comment "Strong signal" ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 """ try: recording = load_recording(input) @@ -412,21 +382,12 @@ def add(input, start, count, label, freq_lower, freq_upper, comment, annotation_ def remove(input, index, output, overwrite, quiet): """Remove annotation by index. -<<<<<<< HEAD - Use 'utils annotate list' to see annotation indices. - - \b - Examples: - utils annotate remove signal.sigmf-data 2 - utils annotate remove file.npy 0 -======= Use 'ria annotate list' to see annotation indices. \b Examples: ria annotate remove signal.sigmf-data 2 ria annotate remove file.npy 0 ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 """ try: recording = load_recording(input) @@ -475,13 +436,8 @@ def clear(input, output, overwrite, force, quiet): \b Examples: -<<<<<<< HEAD - utils annotate clear signal.sigmf-data - utils annotate clear file.npy --force -======= ria annotate clear signal.sigmf-data ria annotate clear file.npy --force ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 """ try: recording = load_recording(input) @@ -576,17 +532,10 @@ def energy( \b Examples: -<<<<<<< HEAD - utils annotate energy capture.sigmf-data --label burst - utils annotate energy signal.npy --threshold 1.5 --min-distance 10000 - utils annotate energy signal.sigmf-data --freq-method obw - utils annotate energy signal.sigmf-data --freq-method full-detected -======= ria annotate energy capture.sigmf-data --label burst ria annotate energy signal.npy --threshold 1.5 --min-distance 10000 ria annotate energy signal.sigmf-data --freq-method obw ria annotate energy signal.sigmf-data --freq-method full-detected ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 """ try: @@ -662,13 +611,8 @@ def cusum(input, label, min_duration, window_size, tolerance, annotation_type, o \b Examples: -<<<<<<< HEAD - utils annotate cusum signal.sigmf-data --min-duration 5.0 - utils annotate cusum data.npy --min-duration 10.0 --label state -======= ria annotate cusum signal.sigmf-data --min-duration 5.0 ria annotate cusum data.npy --min-duration 10.0 --label state ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 """ try: recording = load_recording(input) @@ -714,11 +658,7 @@ def cusum(input, label, min_duration, window_size, tolerance, annotation_type, o @click.argument("input", type=click.Path(exists=True)) @click.option("--threshold", type=float, required=True, help="Threshold (0.0-1.0, fraction of max magnitude)") @click.option("--label", type=str, default=None, help="Annotation label") -<<<<<<< HEAD -@click.option("--window-size", type=int, default=1024, help="Smoothing window size") -======= @click.option("--window-size", type=int, default=None, help="Smoothing window size in samples (default: 1ms at recording sample rate)") ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 @click.option( "--type", "annotation_type", @@ -726,18 +666,11 @@ def cusum(input, label, min_duration, window_size, tolerance, annotation_type, o default="standalone", help="Annotation type", ) -<<<<<<< HEAD -@click.option("--output", "-o", type=click.Path(), help="Output file path") -@click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") -@click.option("--quiet", is_flag=True, help="Quiet mode") -def threshold(input, threshold, label, window_size, annotation_type, output, overwrite, quiet): -======= @click.option("--channel", type=int, default=0, help="Channel index to annotate (default: 0)") @click.option("--output", "-o", type=click.Path(), help="Output file path") @click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") @click.option("--quiet", is_flag=True, help="Quiet mode") def threshold(input, threshold, label, window_size, annotation_type, channel, output, overwrite, quiet): ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 """Auto-detect signals using threshold method. Detects samples above a percentage of maximum magnitude. Best for simple @@ -745,13 +678,8 @@ def threshold(input, threshold, label, window_size, annotation_type, channel, ou \b Examples: -<<<<<<< HEAD - utils annotate threshold signal.sigmf-data --threshold 0.7 --label wifi - utils annotate threshold data.npy --threshold 0.5 --window-size 2048 -======= ria annotate threshold signal.sigmf-data --threshold 0.7 --label wifi ria annotate threshold data.npy --threshold 0.5 --window-size 2048 ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 """ if not (0.0 <= threshold <= 1.0): raise click.ClickException(f"--threshold must be between 0.0 and 1.0, got {threshold}") @@ -766,12 +694,8 @@ def threshold(input, threshold, label, window_size, annotation_type, channel, ou if not quiet: click.echo("\nDetecting signals using threshold qualifier...") click.echo(f" Threshold: {threshold * 100:.1f}% of max magnitude") -<<<<<<< HEAD - click.echo(f" Window size: {window_size} samples") -======= click.echo(f" Window size: {'auto (1ms)' if window_size is None else f'{window_size} samples'}") click.echo(f" Channel: {channel}") ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 try: initial_count = len(recording.annotations) @@ -781,10 +705,7 @@ def threshold(input, threshold, label, window_size, annotation_type, channel, ou window_size=window_size, label=label, annotation_type=annotation_type, -<<<<<<< HEAD -======= channel=channel, ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 ) added = len(recording.annotations) - initial_count @@ -833,17 +754,10 @@ def separate(input, indices, nfft, noise_threshold_db, min_component_bw, output, \b Examples: -<<<<<<< HEAD - utils annotate separate capture.sigmf-data - utils annotate separate signal.npy --indices 0,1,2 - utils annotate separate data.sigmf-data --noise-threshold-db -70 - utils annotate separate signal.npy --min-component-bw 100000 -======= ria annotate separate capture.sigmf-data ria annotate separate signal.npy --indices 0,1,2 ria annotate separate data.sigmf-data --noise-threshold-db -70 ria annotate separate signal.npy --min-component-bw 100000 ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 """ try: diff --git a/src/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py b/src/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py index 1377ac6..53cf37f 100644 --- a/src/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py +++ b/src/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py @@ -2,10 +2,8 @@ """ This module contains all the CLI bindings for the ria package. """ -<<<<<<< HEAD -======= ->>>>>>> 2bb2d9d5a780dbc17172135a5a1f10eba14b1af4 + from .annotate import annotate from .capture import capture from .combine import combine From 5d909c4a22ab1787f9b559ce35a530dc3f8040da Mon Sep 17 00:00:00 2001 From: fordg1 Date: Tue, 31 Mar 2026 15:27:45 -0400 Subject: [PATCH 11/43] Update to be accurate to ria toolkit --- tests/ria_toolkit_oss_cli/README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/ria_toolkit_oss_cli/README.md b/tests/ria_toolkit_oss_cli/README.md index 3e78415..06a5258 100644 --- a/tests/ria_toolkit_oss_cli/README.md +++ b/tests/ria_toolkit_oss_cli/README.md @@ -13,25 +13,25 @@ Comprehensive test suite for the ria CLI commands. ### Run all CLI tests: ```bash -poetry run pytest tests/utils_cli/ -v +poetry run pytest tests/ria_toolkit_oss_cli/ -v ``` ### Run specific test file: ```bash -poetry run pytest tests/utils_cli/test_common.py -v -poetry run pytest tests/utils_cli/test_discover.py -v -poetry run pytest tests/utils_cli/test_capture.py -v +poetry run pytest tests/ria_toolkit_oss_cli/test_common.py -v +poetry run pytest tests/ria_toolkit_oss_cli/test_discover.py -v +poetry run pytest tests/ria_toolkit_oss_cli/test_capture.py -v ``` ### Run specific test class or function: ```bash -poetry run pytest tests/utils_cli/test_capture.py::TestCaptureCommand::test_capture_basic -v -poetry run pytest tests/utils_cli/test_common.py::test_parse_frequency -v +poetry run pytest tests/ria_toolkit_oss_cli/test_capture.py::TestCaptureCommand::test_capture_basic -v +poetry run pytest tests/ria_toolkit_oss_cli/test_common.py::test_parse_frequency -v ``` ### Run with coverage: ```bash -poetry run pytest tests/utils_cli/ --cov=utils_cli --cov-report=html +poetry run pytest tests/ria_toolkit_oss_cli/ --cov=utils_cli --cov-report=html ``` ## Test Coverage From 0e3e0220846582e15b09993a37347132ee336faf Mon Sep 17 00:00:00 2001 From: madrigal Date: Thu, 2 Apr 2026 10:35:21 -0400 Subject: [PATCH 12/43] Updated poetry.lock --- poetry.lock | 2186 +++++++++++++++++++++++++++------------------------ 1 file changed, 1150 insertions(+), 1036 deletions(-) diff --git a/poetry.lock b/poetry.lock index 86d581f..db83521 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.3 and should not be changed by hand. [[package]] name = "alabaster" @@ -14,24 +14,23 @@ files = [ [[package]] name = "anyio" -version = "4.10.0" +version = "4.13.0" description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["docs"] files = [ - {file = "anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1"}, - {file = "anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6"}, + {file = "anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708"}, + {file = "anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc"}, ] [package.dependencies] exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} idna = ">=2.8" -sniffio = ">=1.1" typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] -trio = ["trio (>=0.26.1)"] +trio = ["trio (>=0.32.0)"] [[package]] name = "astroid" @@ -50,34 +49,26 @@ typing-extensions = {version = ">=4", markers = "python_version < \"3.11\""} [[package]] name = "attrs" -version = "25.3.0" +version = "26.1.0" description = "Classes Without Boilerplate" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3"}, - {file = "attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b"}, + {file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"}, + {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, ] -[package.extras] -benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier"] -tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] - [[package]] name = "babel" -version = "2.17.0" +version = "2.18.0" description = "Internationalization utilities" optional = false python-versions = ">=3.8" groups = ["docs"] files = [ - {file = "babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2"}, - {file = "babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d"}, + {file = "babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35"}, + {file = "babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d"}, ] [package.extras] @@ -132,26 +123,26 @@ uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "cachetools" -version = "6.2.0" +version = "7.0.5" description = "Extensible memoizing collections and decorators" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["test"] files = [ - {file = "cachetools-6.2.0-py3-none-any.whl", hash = "sha256:1c76a8960c0041fcc21097e357f882197c79da0dbff766e7317890a65d7d8ba6"}, - {file = "cachetools-6.2.0.tar.gz", hash = "sha256:38b328c0889450f05f5e120f56ab68c8abaf424e1275522b138ffc93253f7e32"}, + {file = "cachetools-7.0.5-py3-none-any.whl", hash = "sha256:46bc8ebefbe485407621d0a4264b23c080cedd913921bad7ac3ed2f26c183114"}, + {file = "cachetools-7.0.5.tar.gz", hash = "sha256:0cd042c24377200c1dcd225f8b7b12b0ca53cc2c961b43757e774ebe190fd990"}, ] [[package]] name = "certifi" -version = "2025.8.3" +version = "2026.2.25" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" groups = ["docs"] files = [ - {file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"}, - {file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"}, + {file = "certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa"}, + {file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"}, ] [[package]] @@ -252,117 +243,155 @@ files = [ [package.dependencies] pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} -[[package]] -name = "chardet" -version = "5.2.0" -description = "Universal encoding detector for Python 3" -optional = false -python-versions = ">=3.7" -groups = ["test"] -files = [ - {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, - {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, -] - [[package]] name = "charset-normalizer" -version = "3.4.3" +version = "3.4.7" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" groups = ["docs"] files = [ - {file = "charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72"}, - {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe"}, - {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601"}, - {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c"}, - {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2"}, - {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0"}, - {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0"}, - {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0"}, - {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a"}, - {file = "charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f"}, - {file = "charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669"}, - {file = "charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b"}, - {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64"}, - {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91"}, - {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f"}, - {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07"}, - {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30"}, - {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14"}, - {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c"}, - {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae"}, - {file = "charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849"}, - {file = "charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c"}, - {file = "charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1"}, - {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884"}, - {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018"}, - {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392"}, - {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f"}, - {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154"}, - {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491"}, - {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93"}, - {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f"}, - {file = "charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37"}, - {file = "charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc"}, - {file = "charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe"}, - {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8"}, - {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9"}, - {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31"}, - {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f"}, - {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927"}, - {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9"}, - {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5"}, - {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc"}, - {file = "charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce"}, - {file = "charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef"}, - {file = "charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15"}, - {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db"}, - {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d"}, - {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096"}, - {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa"}, - {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049"}, - {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0"}, - {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92"}, - {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16"}, - {file = "charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce"}, - {file = "charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c"}, - {file = "charset_normalizer-3.4.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0f2be7e0cf7754b9a30eb01f4295cc3d4358a479843b31f328afd210e2c7598c"}, - {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c60e092517a73c632ec38e290eba714e9627abe9d301c8c8a12ec32c314a2a4b"}, - {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:252098c8c7a873e17dd696ed98bbe91dbacd571da4b87df3736768efa7a792e4"}, - {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3653fad4fe3ed447a596ae8638b437f827234f01a8cd801842e43f3d0a6b281b"}, - {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8999f965f922ae054125286faf9f11bc6932184b93011d138925a1773830bbe9"}, - {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d95bfb53c211b57198bb91c46dd5a2d8018b3af446583aab40074bf7988401cb"}, - {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:5b413b0b1bfd94dbf4023ad6945889f374cd24e3f62de58d6bb102c4d9ae534a"}, - {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:b5e3b2d152e74e100a9e9573837aba24aab611d39428ded46f4e4022ea7d1942"}, - {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:a2d08ac246bb48479170408d6c19f6385fa743e7157d716e144cad849b2dd94b"}, - {file = "charset_normalizer-3.4.3-cp38-cp38-win32.whl", hash = "sha256:ec557499516fc90fd374bf2e32349a2887a876fbf162c160e3c01b6849eaf557"}, - {file = "charset_normalizer-3.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:5d8d01eac18c423815ed4f4a2ec3b439d654e55ee4ad610e153cf02faf67ea40"}, - {file = "charset_normalizer-3.4.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05"}, - {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e"}, - {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99"}, - {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7"}, - {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7"}, - {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19"}, - {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312"}, - {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc"}, - {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34"}, - {file = "charset_normalizer-3.4.3-cp39-cp39-win32.whl", hash = "sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432"}, - {file = "charset_normalizer-3.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca"}, - {file = "charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a"}, - {file = "charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-win32.whl", hash = "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c"}, + {file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"}, + {file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"}, ] [[package]] name = "click" -version = "8.2.1" +version = "8.3.1" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" groups = ["main", "dev", "docs"] files = [ - {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"}, - {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"}, + {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, + {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, ] [package.dependencies] @@ -570,14 +599,14 @@ tests = ["pytest", "pytest-cov", "pytest-xdist"] [[package]] name = "dill" -version = "0.4.0" +version = "0.4.1" description = "serialize all of Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049"}, - {file = "dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0"}, + {file = "dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d"}, + {file = "dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa"}, ] [package.extras] @@ -610,15 +639,15 @@ files = [ [[package]] name = "exceptiongroup" -version = "1.3.0" +version = "1.3.1" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" groups = ["docs", "test"] markers = "python_version == \"3.10\"" files = [ - {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, - {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, + {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, + {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, ] [package.dependencies] @@ -629,14 +658,14 @@ test = ["pytest (>=6)"] [[package]] name = "filelock" -version = "3.19.1" +version = "3.25.2" description = "A platform independent file lock." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["test"] files = [ - {file = "filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d"}, - {file = "filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58"}, + {file = "filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70"}, + {file = "filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694"}, ] [[package]] @@ -658,62 +687,62 @@ pyflakes = ">=3.4.0,<3.5.0" [[package]] name = "fonttools" -version = "4.61.1" +version = "4.62.1" description = "Tools to manipulate font files" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "fonttools-4.61.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c7db70d57e5e1089a274cbb2b1fd635c9a24de809a231b154965d415d6c6d24"}, - {file = "fonttools-4.61.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5fe9fd43882620017add5eabb781ebfbc6998ee49b35bd7f8f79af1f9f99a958"}, - {file = "fonttools-4.61.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8db08051fc9e7d8bc622f2112511b8107d8f27cd89e2f64ec45e9825e8288da"}, - {file = "fonttools-4.61.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a76d4cb80f41ba94a6691264be76435e5f72f2cb3cab0b092a6212855f71c2f6"}, - {file = "fonttools-4.61.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a13fc8aeb24bad755eea8f7f9d409438eb94e82cf86b08fe77a03fbc8f6a96b1"}, - {file = "fonttools-4.61.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b846a1fcf8beadeb9ea4f44ec5bdde393e2f1569e17d700bfc49cd69bde75881"}, - {file = "fonttools-4.61.1-cp310-cp310-win32.whl", hash = "sha256:78a7d3ab09dc47ac1a363a493e6112d8cabed7ba7caad5f54dbe2f08676d1b47"}, - {file = "fonttools-4.61.1-cp310-cp310-win_amd64.whl", hash = "sha256:eff1ac3cc66c2ac7cda1e64b4e2f3ffef474b7335f92fc3833fc632d595fcee6"}, - {file = "fonttools-4.61.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c6604b735bb12fef8e0efd5578c9fb5d3d8532d5001ea13a19cddf295673ee09"}, - {file = "fonttools-4.61.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ce02f38a754f207f2f06557523cd39a06438ba3aafc0639c477ac409fc64e37"}, - {file = "fonttools-4.61.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77efb033d8d7ff233385f30c62c7c79271c8885d5c9657d967ede124671bbdfb"}, - {file = "fonttools-4.61.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:75c1a6dfac6abd407634420c93864a1e274ebc1c7531346d9254c0d8f6ca00f9"}, - {file = "fonttools-4.61.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0de30bfe7745c0d1ffa2b0b7048fb7123ad0d71107e10ee090fa0b16b9452e87"}, - {file = "fonttools-4.61.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:58b0ee0ab5b1fc9921eccfe11d1435added19d6494dde14e323f25ad2bc30c56"}, - {file = "fonttools-4.61.1-cp311-cp311-win32.whl", hash = "sha256:f79b168428351d11e10c5aeb61a74e1851ec221081299f4cf56036a95431c43a"}, - {file = "fonttools-4.61.1-cp311-cp311-win_amd64.whl", hash = "sha256:fe2efccb324948a11dd09d22136fe2ac8a97d6c1347cf0b58a911dcd529f66b7"}, - {file = "fonttools-4.61.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f3cb4a569029b9f291f88aafc927dd53683757e640081ca8c412781ea144565e"}, - {file = "fonttools-4.61.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41a7170d042e8c0024703ed13b71893519a1a6d6e18e933e3ec7507a2c26a4b2"}, - {file = "fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10d88e55330e092940584774ee5e8a6971b01fc2f4d3466a1d6c158230880796"}, - {file = "fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15acc09befd16a0fb8a8f62bc147e1a82817542d72184acca9ce6e0aeda9fa6d"}, - {file = "fonttools-4.61.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e6bcdf33aec38d16508ce61fd81838f24c83c90a1d1b8c68982857038673d6b8"}, - {file = "fonttools-4.61.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5fade934607a523614726119164ff621e8c30e8fa1ffffbbd358662056ba69f0"}, - {file = "fonttools-4.61.1-cp312-cp312-win32.whl", hash = "sha256:75da8f28eff26defba42c52986de97b22106cb8f26515b7c22443ebc9c2d3261"}, - {file = "fonttools-4.61.1-cp312-cp312-win_amd64.whl", hash = "sha256:497c31ce314219888c0e2fce5ad9178ca83fe5230b01a5006726cdf3ac9f24d9"}, - {file = "fonttools-4.61.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c56c488ab471628ff3bfa80964372fc13504ece601e0d97a78ee74126b2045c"}, - {file = "fonttools-4.61.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc492779501fa723b04d0ab1f5be046797fee17d27700476edc7ee9ae535a61e"}, - {file = "fonttools-4.61.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:64102ca87e84261419c3747a0d20f396eb024bdbeb04c2bfb37e2891f5fadcb5"}, - {file = "fonttools-4.61.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c1b526c8d3f615a7b1867f38a9410849c8f4aef078535742198e942fba0e9bd"}, - {file = "fonttools-4.61.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:41ed4b5ec103bd306bb68f81dc166e77409e5209443e5773cb4ed837bcc9b0d3"}, - {file = "fonttools-4.61.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b501c862d4901792adaec7c25b1ecc749e2662543f68bb194c42ba18d6eec98d"}, - {file = "fonttools-4.61.1-cp313-cp313-win32.whl", hash = "sha256:4d7092bb38c53bbc78e9255a59158b150bcdc115a1e3b3ce0b5f267dc35dd63c"}, - {file = "fonttools-4.61.1-cp313-cp313-win_amd64.whl", hash = "sha256:21e7c8d76f62ab13c9472ccf74515ca5b9a761d1bde3265152a6dc58700d895b"}, - {file = "fonttools-4.61.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fff4f534200a04b4a36e7ae3cb74493afe807b517a09e99cb4faa89a34ed6ecd"}, - {file = "fonttools-4.61.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d9203500f7c63545b4ce3799319fe4d9feb1a1b89b28d3cb5abd11b9dd64147e"}, - {file = "fonttools-4.61.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa646ecec9528bef693415c79a86e733c70a4965dd938e9a226b0fc64c9d2e6c"}, - {file = "fonttools-4.61.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11f35ad7805edba3aac1a3710d104592df59f4b957e30108ae0ba6c10b11dd75"}, - {file = "fonttools-4.61.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b931ae8f62db78861b0ff1ac017851764602288575d65b8e8ff1963fed419063"}, - {file = "fonttools-4.61.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b148b56f5de675ee16d45e769e69f87623a4944f7443850bf9a9376e628a89d2"}, - {file = "fonttools-4.61.1-cp314-cp314-win32.whl", hash = "sha256:9b666a475a65f4e839d3d10473fad6d47e0a9db14a2f4a224029c5bfde58ad2c"}, - {file = "fonttools-4.61.1-cp314-cp314-win_amd64.whl", hash = "sha256:4f5686e1fe5fce75d82d93c47a438a25bf0d1319d2843a926f741140b2b16e0c"}, - {file = "fonttools-4.61.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e76ce097e3c57c4bcb67c5aa24a0ecdbd9f74ea9219997a707a4061fbe2707aa"}, - {file = "fonttools-4.61.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9cfef3ab326780c04d6646f68d4b4742aae222e8b8ea1d627c74e38afcbc9d91"}, - {file = "fonttools-4.61.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a75c301f96db737e1c5ed5fd7d77d9c34466de16095a266509e13da09751bd19"}, - {file = "fonttools-4.61.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91669ccac46bbc1d09e9273546181919064e8df73488ea087dcac3e2968df9ba"}, - {file = "fonttools-4.61.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c33ab3ca9d3ccd581d58e989d67554e42d8d4ded94ab3ade3508455fe70e65f7"}, - {file = "fonttools-4.61.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:664c5a68ec406f6b1547946683008576ef8b38275608e1cee6c061828171c118"}, - {file = "fonttools-4.61.1-cp314-cp314t-win32.whl", hash = "sha256:aed04cabe26f30c1647ef0e8fbb207516fd40fe9472e9439695f5c6998e60ac5"}, - {file = "fonttools-4.61.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2180f14c141d2f0f3da43f3a81bc8aa4684860f6b0e6f9e165a4831f24e6a23b"}, - {file = "fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371"}, - {file = "fonttools-4.61.1.tar.gz", hash = "sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69"}, + {file = "fonttools-4.62.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ad5cca75776cd453b1b035b530e943334957ae152a36a88a320e779d61fc980c"}, + {file = "fonttools-4.62.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b3ae47e8636156a9accff64c02c0924cbebad62854c4a6dbdc110cd5b4b341a"}, + {file = "fonttools-4.62.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b9e288b4da2f64fd6180644221749de651703e8d0c16bd4b719533a3a7d6e3"}, + {file = "fonttools-4.62.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bca7a1c1faf235ffe25d4f2e555246b4750220b38de8261d94ebc5ce8a23c23"}, + {file = "fonttools-4.62.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4e0fcf265ad26e487c56cb12a42dffe7162de708762db951e1b3f755319507d"}, + {file = "fonttools-4.62.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2d850f66830a27b0d498ee05adb13a3781637b1826982cd7e2b3789ef0cc71ae"}, + {file = "fonttools-4.62.1-cp310-cp310-win32.whl", hash = "sha256:486f32c8047ccd05652aba17e4a8819a3a9d78570eb8a0e3b4503142947880ed"}, + {file = "fonttools-4.62.1-cp310-cp310-win_amd64.whl", hash = "sha256:5a648bde915fba9da05ae98856987ca91ba832949a9e2888b48c47ef8b96c5a9"}, + {file = "fonttools-4.62.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:40975849bac44fb0b9253d77420c6d8b523ac4dcdcefeff6e4d706838a5b80f7"}, + {file = "fonttools-4.62.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9dde91633f77fa576879a0c76b1d89de373cae751a98ddf0109d54e173b40f14"}, + {file = "fonttools-4.62.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6acb4109f8bee00fec985c8c7afb02299e35e9c94b57287f3ea542f28bd0b0a7"}, + {file = "fonttools-4.62.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1c5c25671ce8805e0d080e2ffdeca7f1e86778c5cbfbeae86d7f866d8830517b"}, + {file = "fonttools-4.62.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a5d8825e1140f04e6c99bb7d37a9e31c172f3bc208afbe02175339e699c710e1"}, + {file = "fonttools-4.62.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:268abb1cb221e66c014acc234e872b7870d8b5d4657a83a8f4205094c32d2416"}, + {file = "fonttools-4.62.1-cp311-cp311-win32.whl", hash = "sha256:942b03094d7edbb99bdf1ae7e9090898cad7bf9030b3d21f33d7072dbcb51a53"}, + {file = "fonttools-4.62.1-cp311-cp311-win_amd64.whl", hash = "sha256:e8514f4924375f77084e81467e63238b095abda5107620f49421c368a6017ed2"}, + {file = "fonttools-4.62.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974"}, + {file = "fonttools-4.62.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9"}, + {file = "fonttools-4.62.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936"}, + {file = "fonttools-4.62.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392"}, + {file = "fonttools-4.62.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04"}, + {file = "fonttools-4.62.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d"}, + {file = "fonttools-4.62.1-cp312-cp312-win32.whl", hash = "sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c"}, + {file = "fonttools-4.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42"}, + {file = "fonttools-4.62.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c22b1014017111c401469e3acc5433e6acf6ebcc6aa9efb538a533c800971c79"}, + {file = "fonttools-4.62.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68959f5fc58ed4599b44aad161c2837477d7f35f5f79402d97439974faebfebe"}, + {file = "fonttools-4.62.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68"}, + {file = "fonttools-4.62.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1"}, + {file = "fonttools-4.62.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e7abd2b1e11736f58c1de27819e1955a53267c21732e78243fa2fa2e5c1e069"}, + {file = "fonttools-4.62.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9"}, + {file = "fonttools-4.62.1-cp313-cp313-win32.whl", hash = "sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24"}, + {file = "fonttools-4.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056"}, + {file = "fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca"}, + {file = "fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca"}, + {file = "fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782"}, + {file = "fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae"}, + {file = "fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7"}, + {file = "fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a"}, + {file = "fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800"}, + {file = "fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e"}, + {file = "fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82"}, + {file = "fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260"}, + {file = "fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4"}, + {file = "fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b"}, + {file = "fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87"}, + {file = "fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c"}, + {file = "fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a"}, + {file = "fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e"}, + {file = "fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd"}, + {file = "fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d"}, ] [package.extras] @@ -743,53 +772,75 @@ files = [ [[package]] name = "h5py" -version = "3.14.0" +version = "3.16.0" description = "Read and write HDF5 files from Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "h5py-3.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:24df6b2622f426857bda88683b16630014588a0e4155cba44e872eb011c4eaed"}, - {file = "h5py-3.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6ff2389961ee5872de697054dd5a033b04284afc3fb52dc51d94561ece2c10c6"}, - {file = "h5py-3.14.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:016e89d3be4c44f8d5e115fab60548e518ecd9efe9fa5c5324505a90773e6f03"}, - {file = "h5py-3.14.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1223b902ef0b5d90bcc8a4778218d6d6cd0f5561861611eda59fa6c52b922f4d"}, - {file = "h5py-3.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:852b81f71df4bb9e27d407b43071d1da330d6a7094a588efa50ef02553fa7ce4"}, - {file = "h5py-3.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f30dbc58f2a0efeec6c8836c97f6c94afd769023f44e2bb0ed7b17a16ec46088"}, - {file = "h5py-3.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:543877d7f3d8f8a9828ed5df6a0b78ca3d8846244b9702e99ed0d53610b583a8"}, - {file = "h5py-3.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c497600c0496548810047257e36360ff551df8b59156d3a4181072eed47d8ad"}, - {file = "h5py-3.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:723a40ee6505bd354bfd26385f2dae7bbfa87655f4e61bab175a49d72ebfc06b"}, - {file = "h5py-3.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:d2744b520440a996f2dae97f901caa8a953afc055db4673a993f2d87d7f38713"}, - {file = "h5py-3.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e0045115d83272090b0717c555a31398c2c089b87d212ceba800d3dc5d952e23"}, - {file = "h5py-3.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6da62509b7e1d71a7d110478aa25d245dd32c8d9a1daee9d2a42dba8717b047a"}, - {file = "h5py-3.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:554ef0ced3571366d4d383427c00c966c360e178b5fb5ee5bb31a435c424db0c"}, - {file = "h5py-3.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cbd41f4e3761f150aa5b662df991868ca533872c95467216f2bec5fcad84882"}, - {file = "h5py-3.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:bf4897d67e613ecf5bdfbdab39a1158a64df105827da70ea1d90243d796d367f"}, - {file = "h5py-3.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:aa4b7bbce683379b7bf80aaba68e17e23396100336a8d500206520052be2f812"}, - {file = "h5py-3.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ef9603a501a04fcd0ba28dd8f0995303d26a77a980a1f9474b3417543d4c6174"}, - {file = "h5py-3.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8cbaf6910fa3983c46172666b0b8da7b7bd90d764399ca983236f2400436eeb"}, - {file = "h5py-3.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d90e6445ab7c146d7f7981b11895d70bc1dd91278a4f9f9028bc0c95e4a53f13"}, - {file = "h5py-3.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:ae18e3de237a7a830adb76aaa68ad438d85fe6e19e0d99944a3ce46b772c69b3"}, - {file = "h5py-3.14.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5cc1601e78027cedfec6dd50efb4802f018551754191aeb58d948bd3ec3bd7a"}, - {file = "h5py-3.14.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5e59d2136a8b302afd25acdf7a89b634e0eb7c66b1a211ef2d0457853768a2ef"}, - {file = "h5py-3.14.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:573c33ad056ac7c1ab6d567b6db9df3ffc401045e3f605736218f96c1e0490c6"}, - {file = "h5py-3.14.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ccbe17dc187c0c64178f1a10aa274ed3a57d055117588942b8a08793cc448216"}, - {file = "h5py-3.14.0-cp39-cp39-win_amd64.whl", hash = "sha256:4f025cf30ae738c4c4e38c7439a761a71ccfcce04c2b87b2a2ac64e8c5171d43"}, - {file = "h5py-3.14.0.tar.gz", hash = "sha256:2372116b2e0d5d3e5e705b7f663f7c8d96fa79a4052d250484ef91d24d6a08f4"}, + {file = "h5py-3.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e06f864bedb2c8e7c1358e6c73af48519e317457c444d6f3d332bb4e8fa6d7d9"}, + {file = "h5py-3.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ec86d4fffd87a0f4cb3d5796ceb5a50123a2a6d99b43e616e5504e66a953eca3"}, + {file = "h5py-3.16.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:86385ea895508220b8a7e45efa428aeafaa586bd737c7af9ee04661d8d84a10d"}, + {file = "h5py-3.16.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:8975273c2c5921c25700193b408e28d6bdd0111c37468b2d4e25dcec4cd1d84d"}, + {file = "h5py-3.16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1677ad48b703f44efc9ea0c3ab284527f81bc4f318386aaaebc5fede6bbae56f"}, + {file = "h5py-3.16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c4dd4cf5f0a4e36083f73172f6cfc25a5710789269547f132a20975bfe2434c"}, + {file = "h5py-3.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:bdef06507725b455fccba9c16529121a5e1fbf56aa375f7d9713d9e8ff42454d"}, + {file = "h5py-3.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:719439d14b83f74eeb080e9650a6c7aa6d0d9ea0ca7f804347b05fac6fbf18af"}, + {file = "h5py-3.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c3f0a0e136f2e95dd0b67146abb6668af4f1a69c81ef8651a2d316e8e01de447"}, + {file = "h5py-3.16.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:a6fbc5367d4046801f9b7db9191b31895f22f1c6df1f9987d667854cac493538"}, + {file = "h5py-3.16.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:fb1720028d99040792bb2fb31facb8da44a6f29df7697e0b84f0d79aff2e9bd3"}, + {file = "h5py-3.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:314b6054fe0b1051c2b0cb2df5cbdab15622fb05e80f202e3b6a5eee0d6fe365"}, + {file = "h5py-3.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ffbab2fedd6581f6aa31cf1639ca2cb86e02779de525667892ebf4cc9fd26434"}, + {file = "h5py-3.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:17d1f1630f92ad74494a9a7392ab25982ce2b469fc62da6074c0ce48366a2999"}, + {file = "h5py-3.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:85b9c49dd58dc44cf70af944784e2c2038b6f799665d0dcbbc812a26e0faa859"}, + {file = "h5py-3.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c5313566f4643121a78503a473f0fb1e6dcc541d5115c44f05e037609c565c4d"}, + {file = "h5py-3.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:42b012933a83e1a558c673176676a10ce2fd3759976a0fedee1e672d1e04fc9d"}, + {file = "h5py-3.16.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ff24039e2573297787c3063df64b60aab0591980ac898329a08b0320e0cf2527"}, + {file = "h5py-3.16.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:dfc21898ff025f1e8e67e194965a95a8d4754f452f83454538f98f8a3fcb207e"}, + {file = "h5py-3.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:698dd69291272642ffda44a0ecd6cd3bda5faf9621452d255f57ce91487b9794"}, + {file = "h5py-3.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2b2c02b0a160faed5fb33f1ba8a264a37ee240b22e049ecc827345d0d9043074"}, + {file = "h5py-3.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:96b422019a1c8975c2d5dadcf61d4ba6f01c31f92bbde6e4649607885fe502d6"}, + {file = "h5py-3.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:39c2838fb1e8d97bcf1755e60ad1f3dd76a7b2a475928dc321672752678b96db"}, + {file = "h5py-3.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:370a845f432c2c9619db8eed334d1e610c6015796122b0e57aa46312c22617d9"}, + {file = "h5py-3.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42108e93326c50c2810025aade9eac9d6827524cdccc7d4b75a546e5ab308edb"}, + {file = "h5py-3.16.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:099f2525c9dcf28de366970a5fb34879aab20491589fa89ce2863a84218bb524"}, + {file = "h5py-3.16.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9300ad32dea9dfc5171f94d5f6948e159ed93e4701280b0f508773b3f582f402"}, + {file = "h5py-3.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:171038f23bccddfc23f344cadabdfc9917ff554db6a0d417180d2747fe4c75a7"}, + {file = "h5py-3.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7e420b539fb6023a259a1b14d4c9f6df8cf50d7268f48e161169987a57b737ff"}, + {file = "h5py-3.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:18f2bbcd545e6991412253b98727374c356d67caa920e68dc79eab36bf5fedad"}, + {file = "h5py-3.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:656f00e4d903199a1d58df06b711cf3ca632b874b4207b7dbec86185b5c8c7d4"}, + {file = "h5py-3.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9c9d307c0ef862d1cd5714f72ecfafe0a5d7529c44845afa8de9f46e5ba8bd65"}, + {file = "h5py-3.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8c1eff849cdd53cbc73c214c30ebdb6f1bb8b64790b4b4fc36acdb5e43570210"}, + {file = "h5py-3.16.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:e2c04d129f180019e216ee5f9c40b78a418634091c8782e1f723a6ca3658b965"}, + {file = "h5py-3.16.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e4360f15875a532bc7b98196c7592ed4fc92672a57c0a621355961cafb17a6dd"}, + {file = "h5py-3.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3fae9197390c325e62e0a1aa977f2f62d994aa87aab182abbea85479b791197c"}, + {file = "h5py-3.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:43259303989ac8adacc9986695b31e35dba6fd1e297ff9c6a04b7da5542139cc"}, + {file = "h5py-3.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:fa48993a0b799737ba7fd21e2350fa0a60701e58180fae9f2de834bc39a147ab"}, + {file = "h5py-3.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:1897a771a7f40d05c262fc8f37376ec37873218544b70216872876c627640f63"}, + {file = "h5py-3.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:15922e485844f77c0b9d275396d435db3baa58292a9c2176a386e072e0cf2491"}, + {file = "h5py-3.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:df02dd29bd247f98674634dfe41f89fd7c16ba3d7de8695ec958f58404a4e618"}, + {file = "h5py-3.16.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:0f456f556e4e2cebeebd9d66adf8dc321770a42593494a0b6f0af54a7567b242"}, + {file = "h5py-3.16.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:3e6cb3387c756de6a9492d601553dffea3fe11b5f22b443aac708c69f3f55e16"}, + {file = "h5py-3.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8389e13a1fd745ad2856873e8187fd10268b2d9677877bb667b41aebd771d8b7"}, + {file = "h5py-3.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:346df559a0f7dcb31cf8e44805319e2ab24b8957c45e7708ce503b2ec79ba725"}, + {file = "h5py-3.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4c6ab014ab704b4feaa719ae783b86522ed0bf1f82184704ed3c9e4e3228796e"}, + {file = "h5py-3.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:faca8fb4e4319c09d83337adc80b2ca7d5c5a343c2d6f1b6388f32cfecca13c1"}, + {file = "h5py-3.16.0.tar.gz", hash = "sha256:a0dbaad796840ccaa67a4c144a0d0c8080073c34c76d5a6941d6818678ef2738"}, ] [package.dependencies] -numpy = ">=1.19.3" +numpy = ">=1.21.2" [[package]] name = "idna" -version = "3.10" +version = "3.11" description = "Internationalized Domain Names in Applications (IDNA)" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" groups = ["docs"] files = [ - {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, - {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, + {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, + {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, ] [package.extras] @@ -797,26 +848,40 @@ all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2 [[package]] name = "imagesize" -version = "1.4.1" +version = "1.5.0" description = "Getting image size from png/jpeg/jpeg2000/gif file" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" groups = ["docs"] +markers = "python_version >= \"3.12\"" files = [ - {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, - {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, + {file = "imagesize-1.5.0-py2.py3-none-any.whl", hash = "sha256:32677681b3f434c2cb496f00e89c5a291247b35b1f527589909e008057da5899"}, + {file = "imagesize-1.5.0.tar.gz", hash = "sha256:8bfc5363a7f2133a89f0098451e0bcb1cd71aba4dc02bbcecb39d99d40e1b94f"}, +] + +[[package]] +name = "imagesize" +version = "2.0.0" +description = "Get image size from headers (BMP/PNG/JPEG/JPEG2000/GIF/TIFF/SVG/Netpbm/WebP/AVIF/HEIC/HEIF)" +optional = false +python-versions = "<3.15,>=3.10" +groups = ["docs"] +markers = "python_version < \"3.12\"" +files = [ + {file = "imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96"}, + {file = "imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3"}, ] [[package]] name = "iniconfig" -version = "2.1.0" +version = "2.3.0" description = "brain-dead simple config-ini parsing" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["test"] files = [ - {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, - {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, ] [[package]] @@ -854,21 +919,21 @@ i18n = ["Babel (>=2.7)"] [[package]] name = "jsonschema" -version = "4.25.1" +version = "4.26.0" description = "An implementation of JSON Schema validation for Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63"}, - {file = "jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85"}, + {file = "jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce"}, + {file = "jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326"}, ] [package.dependencies] attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.03.6" +jsonschema-specifications = ">=2023.3.6" referencing = ">=0.28.4" -rpds-py = ">=0.7.1" +rpds-py = ">=0.25.0" [package.extras] format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] @@ -876,14 +941,14 @@ format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339- [[package]] name = "jsonschema-specifications" -version = "2025.4.1" +version = "2025.9.1" description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "jsonschema_specifications-2025.4.1-py3-none-any.whl", hash = "sha256:4653bffbd6584f7de83a67e0d620ef16900b390ddc7939d56684d6c81e33f1af"}, - {file = "jsonschema_specifications-2025.4.1.tar.gz", hash = "sha256:630159c9f4dbea161a6a2205c3011cc4f18ff381b189fff48bb39b9bf26ae608"}, + {file = "jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe"}, + {file = "jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d"}, ] [package.dependencies] @@ -891,184 +956,228 @@ referencing = ">=0.31.0" [[package]] name = "kiwisolver" -version = "1.4.9" +version = "1.5.0" description = "A fast implementation of the Cassowary constraint solver" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "kiwisolver-1.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b4b4d74bda2b8ebf4da5bd42af11d02d04428b2c32846e4c2c93219df8a7987b"}, - {file = "kiwisolver-1.4.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fb3b8132019ea572f4611d770991000d7f58127560c4889729248eb5852a102f"}, - {file = "kiwisolver-1.4.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84fd60810829c27ae375114cd379da1fa65e6918e1da405f356a775d49a62bcf"}, - {file = "kiwisolver-1.4.9-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b78efa4c6e804ecdf727e580dbb9cba85624d2e1c6b5cb059c66290063bd99a9"}, - {file = "kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4efec7bcf21671db6a3294ff301d2fc861c31faa3c8740d1a94689234d1b415"}, - {file = "kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90f47e70293fc3688b71271100a1a5453aa9944a81d27ff779c108372cf5567b"}, - {file = "kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fdca1def57a2e88ef339de1737a1449d6dbf5fab184c54a1fca01d541317154"}, - {file = "kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9cf554f21be770f5111a1690d42313e140355e687e05cf82cb23d0a721a64a48"}, - {file = "kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1795ac5cd0510207482c3d1d3ed781143383b8cfd36f5c645f3897ce066220"}, - {file = "kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ccd09f20ccdbbd341b21a67ab50a119b64a403b09288c27481575105283c1586"}, - {file = "kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:540c7c72324d864406a009d72f5d6856f49693db95d1fbb46cf86febef873634"}, - {file = "kiwisolver-1.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:ede8c6d533bc6601a47ad4046080d36b8fc99f81e6f1c17b0ac3c2dc91ac7611"}, - {file = "kiwisolver-1.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:7b4da0d01ac866a57dd61ac258c5607b4cd677f63abaec7b148354d2b2cdd536"}, - {file = "kiwisolver-1.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eb14a5da6dc7642b0f3a18f13654847cd8b7a2550e2645a5bda677862b03ba16"}, - {file = "kiwisolver-1.4.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:39a219e1c81ae3b103643d2aedb90f1ef22650deb266ff12a19e7773f3e5f089"}, - {file = "kiwisolver-1.4.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2405a7d98604b87f3fc28b1716783534b1b4b8510d8142adca34ee0bc3c87543"}, - {file = "kiwisolver-1.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dc1ae486f9abcef254b5618dfb4113dd49f94c68e3e027d03cf0143f3f772b61"}, - {file = "kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1f570ce4d62d718dce3f179ee78dac3b545ac16c0c04bb363b7607a949c0d1"}, - {file = "kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb27e7b78d716c591e88e0a09a2139c6577865d7f2e152488c2cc6257f460872"}, - {file = "kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15163165efc2f627eb9687ea5f3a28137217d217ac4024893d753f46bce9de26"}, - {file = "kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bdee92c56a71d2b24c33a7d4c2856bd6419d017e08caa7802d2963870e315028"}, - {file = "kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:412f287c55a6f54b0650bd9b6dce5aceddb95864a1a90c87af16979d37c89771"}, - {file = "kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2c93f00dcba2eea70af2be5f11a830a742fe6b579a1d4e00f47760ef13be247a"}, - {file = "kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f117e1a089d9411663a3207ba874f31be9ac8eaa5b533787024dc07aeb74f464"}, - {file = "kiwisolver-1.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:be6a04e6c79819c9a8c2373317d19a96048e5a3f90bec587787e86a1153883c2"}, - {file = "kiwisolver-1.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:0ae37737256ba2de764ddc12aed4956460277f00c4996d51a197e72f62f5eec7"}, - {file = "kiwisolver-1.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac5a486ac389dddcc5bef4f365b6ae3ffff2c433324fb38dd35e3fab7c957999"}, - {file = "kiwisolver-1.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2ba92255faa7309d06fe44c3a4a97efe1c8d640c2a79a5ef728b685762a6fd2"}, - {file = "kiwisolver-1.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a2899935e724dd1074cb568ce7ac0dce28b2cd6ab539c8e001a8578eb106d14"}, - {file = "kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f6008a4919fdbc0b0097089f67a1eb55d950ed7e90ce2cc3e640abadd2757a04"}, - {file = "kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67bb8b474b4181770f926f7b7d2f8c0248cbcb78b660fdd41a47054b28d2a752"}, - {file = "kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2327a4a30d3ee07d2fbe2e7933e8a37c591663b96ce42a00bc67461a87d7df77"}, - {file = "kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a08b491ec91b1d5053ac177afe5290adacf1f0f6307d771ccac5de30592d198"}, - {file = "kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8fc5c867c22b828001b6a38d2eaeb88160bf5783c6cb4a5e440efc981ce286d"}, - {file = "kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3b3115b2581ea35bb6d1f24a4c90af37e5d9b49dcff267eeed14c3893c5b86ab"}, - {file = "kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858e4c22fb075920b96a291928cb7dea5644e94c0ee4fcd5af7e865655e4ccf2"}, - {file = "kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed0fecd28cc62c54b262e3736f8bb2512d8dcfdc2bcf08be5f47f96bf405b145"}, - {file = "kiwisolver-1.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:f68208a520c3d86ea51acf688a3e3002615a7f0238002cccc17affecc86a8a54"}, - {file = "kiwisolver-1.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:2c1a4f57df73965f3f14df20b80ee29e6a7930a57d2d9e8491a25f676e197c60"}, - {file = "kiwisolver-1.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5d0432ccf1c7ab14f9949eec60c5d1f924f17c037e9f8b33352fa05799359b8"}, - {file = "kiwisolver-1.4.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efb3a45b35622bb6c16dbfab491a8f5a391fe0e9d45ef32f4df85658232ca0e2"}, - {file = "kiwisolver-1.4.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a12cf6398e8a0a001a059747a1cbf24705e18fe413bc22de7b3d15c67cffe3f"}, - {file = "kiwisolver-1.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b67e6efbf68e077dd71d1a6b37e43e1a99d0bff1a3d51867d45ee8908b931098"}, - {file = "kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5656aa670507437af0207645273ccdfee4f14bacd7f7c67a4306d0dcaeaf6eed"}, - {file = "kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bfc08add558155345129c7803b3671cf195e6a56e7a12f3dde7c57d9b417f525"}, - {file = "kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:40092754720b174e6ccf9e845d0d8c7d8e12c3d71e7fc35f55f3813e96376f78"}, - {file = "kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497d05f29a1300d14e02e6441cf0f5ee81c1ff5a304b0d9fb77423974684e08b"}, - {file = "kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bdd1a81a1860476eb41ac4bc1e07b3f07259e6d55bbf739b79c8aaedcf512799"}, - {file = "kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e6b93f13371d341afee3be9f7c5964e3fe61d5fa30f6a30eb49856935dfe4fc3"}, - {file = "kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d75aa530ccfaa593da12834b86a0724f58bff12706659baa9227c2ccaa06264c"}, - {file = "kiwisolver-1.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:dd0a578400839256df88c16abddf9ba14813ec5f21362e1fe65022e00c883d4d"}, - {file = "kiwisolver-1.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:d4188e73af84ca82468f09cadc5ac4db578109e52acb4518d8154698d3a87ca2"}, - {file = "kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5a0f2724dfd4e3b3ac5a82436a8e6fd16baa7d507117e4279b660fe8ca38a3a1"}, - {file = "kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1b11d6a633e4ed84fc0ddafd4ebfd8ea49b3f25082c04ad12b8315c11d504dc1"}, - {file = "kiwisolver-1.4.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61874cdb0a36016354853593cffc38e56fc9ca5aa97d2c05d3dcf6922cd55a11"}, - {file = "kiwisolver-1.4.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:60c439763a969a6af93b4881db0eed8fadf93ee98e18cbc35bc8da868d0c4f0c"}, - {file = "kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92a2f997387a1b79a75e7803aa7ded2cfbe2823852ccf1ba3bcf613b62ae3197"}, - {file = "kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31d512c812daea6d8b3be3b2bfcbeb091dbb09177706569bcfc6240dcf8b41c"}, - {file = "kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:52a15b0f35dad39862d376df10c5230155243a2c1a436e39eb55623ccbd68185"}, - {file = "kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a30fd6fdef1430fd9e1ba7b3398b5ee4e2887783917a687d86ba69985fb08748"}, - {file = "kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cc9617b46837c6468197b5945e196ee9ca43057bb7d9d1ae688101e4e1dddf64"}, - {file = "kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:0ab74e19f6a2b027ea4f845a78827969af45ce790e6cb3e1ebab71bdf9f215ff"}, - {file = "kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dba5ee5d3981160c28d5490f0d1b7ed730c22470ff7f6cc26cfcfaacb9896a07"}, - {file = "kiwisolver-1.4.9-cp313-cp313t-win_arm64.whl", hash = "sha256:0749fd8f4218ad2e851e11cc4dc05c7cbc0cbc4267bdfdb31782e65aace4ee9c"}, - {file = "kiwisolver-1.4.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9928fe1eb816d11ae170885a74d074f57af3a0d65777ca47e9aeb854a1fba386"}, - {file = "kiwisolver-1.4.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d0005b053977e7b43388ddec89fa567f43d4f6d5c2c0affe57de5ebf290dc552"}, - {file = "kiwisolver-1.4.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2635d352d67458b66fd0667c14cb1d4145e9560d503219034a18a87e971ce4f3"}, - {file = "kiwisolver-1.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:767c23ad1c58c9e827b649a9ab7809fd5fd9db266a9cf02b0e926ddc2c680d58"}, - {file = "kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72d0eb9fba308b8311685c2268cf7d0a0639a6cd027d8128659f72bdd8a024b4"}, - {file = "kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f68e4f3eeca8fb22cc3d731f9715a13b652795ef657a13df1ad0c7dc0e9731df"}, - {file = "kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d84cd4061ae292d8ac367b2c3fa3aad11cb8625a95d135fe93f286f914f3f5a6"}, - {file = "kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a60ea74330b91bd22a29638940d115df9dc00af5035a9a2a6ad9399ffb4ceca5"}, - {file = "kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ce6a3a4e106cf35c2d9c4fa17c05ce0b180db622736845d4315519397a77beaf"}, - {file = "kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:77937e5e2a38a7b48eef0585114fe7930346993a88060d0bf886086d2aa49ef5"}, - {file = "kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:24c175051354f4a28c5d6a31c93906dc653e2bf234e8a4bbfb964892078898ce"}, - {file = "kiwisolver-1.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:0763515d4df10edf6d06a3c19734e2566368980d21ebec439f33f9eb936c07b7"}, - {file = "kiwisolver-1.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:0e4e2bf29574a6a7b7f6cb5fa69293b9f96c928949ac4a53ba3f525dffb87f9c"}, - {file = "kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d976bbb382b202f71c67f77b0ac11244021cfa3f7dfd9e562eefcea2df711548"}, - {file = "kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2489e4e5d7ef9a1c300a5e0196e43d9c739f066ef23270607d45aba368b91f2d"}, - {file = "kiwisolver-1.4.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e2ea9f7ab7fbf18fffb1b5434ce7c69a07582f7acc7717720f1d69f3e806f90c"}, - {file = "kiwisolver-1.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b34e51affded8faee0dfdb705416153819d8ea9250bbbf7ea1b249bdeb5f1122"}, - {file = "kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8aacd3d4b33b772542b2e01beb50187536967b514b00003bdda7589722d2a64"}, - {file = "kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7cf974dd4e35fa315563ac99d6287a1024e4dc2077b8a7d7cd3d2fb65d283134"}, - {file = "kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85bd218b5ecfbee8c8a82e121802dcb519a86044c9c3b2e4aef02fa05c6da370"}, - {file = "kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0856e241c2d3df4efef7c04a1e46b1936b6120c9bcf36dd216e3acd84bc4fb21"}, - {file = "kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9af39d6551f97d31a4deebeac6f45b156f9755ddc59c07b402c148f5dbb6482a"}, - {file = "kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:bb4ae2b57fc1d8cbd1cf7b1d9913803681ffa903e7488012be5b76dedf49297f"}, - {file = "kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aedff62918805fb62d43a4aa2ecd4482c380dc76cd31bd7c8878588a61bd0369"}, - {file = "kiwisolver-1.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:1fa333e8b2ce4d9660f2cda9c0e1b6bafcfb2457a9d259faa82289e73ec24891"}, - {file = "kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32"}, - {file = "kiwisolver-1.4.9-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4d1d9e582ad4d63062d34077a9a1e9f3c34088a2ec5135b1f7190c07cf366527"}, - {file = "kiwisolver-1.4.9-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:deed0c7258ceb4c44ad5ec7d9918f9f14fd05b2be86378d86cf50e63d1e7b771"}, - {file = "kiwisolver-1.4.9-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a590506f303f512dff6b7f75fd2fd18e16943efee932008fe7140e5fa91d80e"}, - {file = "kiwisolver-1.4.9-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e09c2279a4d01f099f52d5c4b3d9e208e91edcbd1a175c9662a8b16e000fece9"}, - {file = "kiwisolver-1.4.9-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c9e7cdf45d594ee04d5be1b24dd9d49f3d1590959b2271fb30b5ca2b262c00fb"}, - {file = "kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:720e05574713db64c356e86732c0f3c5252818d05f9df320f0ad8380641acea5"}, - {file = "kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:17680d737d5335b552994a2008fab4c851bcd7de33094a82067ef3a576ff02fa"}, - {file = "kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85b5352f94e490c028926ea567fc569c52ec79ce131dadb968d3853e809518c2"}, - {file = "kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:464415881e4801295659462c49461a24fb107c140de781d55518c4b80cb6790f"}, - {file = "kiwisolver-1.4.9-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fb940820c63a9590d31d88b815e7a3aa5915cad3ce735ab45f0c730b39547de1"}, - {file = "kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d"}, + {file = "kiwisolver-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:32cc0a5365239a6ea0c6ed461e8838d053b57e397443c0ca894dcc8e388d4374"}, + {file = "kiwisolver-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc0b66c1eec9021353a4b4483afb12dfd50e3669ffbb9152d6842eb34c7e29fd"}, + {file = "kiwisolver-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:86e0287879f75621ae85197b0877ed2f8b7aa57b511c7331dce2eb6f4de7d476"}, + {file = "kiwisolver-1.5.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:62f59da443c4f4849f73a51a193b1d9d258dcad0c41bc4d1b8fb2bcc04bfeb22"}, + {file = "kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9190426b7aa26c5229501fa297b8d0653cfd3f5a36f7990c264e157cbf886b3b"}, + {file = "kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8277104ded0a51e699c8c3aff63ce2c56d4ed5519a5f73e0fd7057f959a2b9e"}, + {file = "kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8f9baf6f0a6e7571c45c8863010b45e837c3ee1c2c77fcd6ef423be91b21fedb"}, + {file = "kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cff8e5383db4989311f99e814feeb90c4723eb4edca425b9d5d9c3fefcdd9537"}, + {file = "kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ebae99ed6764f2b5771c522477b311be313e8841d2e0376db2b10922daebbba4"}, + {file = "kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:d5cd5189fc2b6a538b75ae45433140c4823463918f7b1617c31e68b085c0022c"}, + {file = "kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f42c23db5d1521218a3276bb08666dcb662896a0be7347cba864eca45ff64ede"}, + {file = "kiwisolver-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:94eff26096eb5395136634622515b234ecb6c9979824c1f5004c6e3c3c85ccd2"}, + {file = "kiwisolver-1.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:dd952e03bfbb096cfe2dd35cd9e00f269969b67536cb4370994afc20ff2d0875"}, + {file = "kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c"}, + {file = "kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb"}, + {file = "kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac"}, + {file = "kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27"}, + {file = "kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398"}, + {file = "kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db"}, + {file = "kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc"}, + {file = "kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679"}, + {file = "kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309"}, + {file = "kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2"}, + {file = "kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c"}, + {file = "kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08"}, + {file = "kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4"}, + {file = "kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b"}, + {file = "kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac"}, + {file = "kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9"}, + {file = "kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588"}, + {file = "kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819"}, + {file = "kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f"}, + {file = "kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf"}, + {file = "kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d"}, + {file = "kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083"}, + {file = "kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6"}, + {file = "kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1"}, + {file = "kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0"}, + {file = "kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15"}, + {file = "kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314"}, + {file = "kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9"}, + {file = "kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384"}, + {file = "kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7"}, + {file = "kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09"}, + {file = "kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3"}, + {file = "kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd"}, + {file = "kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3"}, + {file = "kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96"}, + {file = "kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099"}, + {file = "kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8"}, + {file = "kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87"}, + {file = "kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23"}, + {file = "kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859"}, + {file = "kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902"}, + {file = "kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167"}, + {file = "kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0"}, + {file = "kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276"}, + {file = "kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c"}, + {file = "kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1"}, + {file = "kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e"}, + {file = "kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7"}, + {file = "kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c"}, + {file = "kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368"}, + {file = "kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489"}, + {file = "kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1"}, + {file = "kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3"}, + {file = "kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18"}, + {file = "kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021"}, + {file = "kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310"}, + {file = "kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3"}, + {file = "kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2"}, + {file = "kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53"}, + {file = "kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615"}, + {file = "kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02"}, + {file = "kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e"}, + {file = "kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac"}, + {file = "kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05"}, + {file = "kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd"}, + {file = "kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a"}, + {file = "kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554"}, + {file = "kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581"}, + {file = "kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303"}, + {file = "kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9"}, + {file = "kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79"}, + {file = "kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796"}, + {file = "kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e"}, + {file = "kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df"}, + {file = "kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e"}, + {file = "kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4"}, + {file = "kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028"}, + {file = "kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657"}, + {file = "kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920"}, + {file = "kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9"}, + {file = "kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d"}, + {file = "kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65"}, + {file = "kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa"}, + {file = "kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0"}, + {file = "kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9"}, + {file = "kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f"}, + {file = "kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646"}, + {file = "kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681"}, + {file = "kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57"}, + {file = "kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797"}, + {file = "kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203"}, + {file = "kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7"}, + {file = "kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57"}, + {file = "kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:295d9ffe712caa9f8a3081de8d32fc60191b4b51c76f02f951fd8407253528f4"}, + {file = "kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:51e8c4084897de9f05898c2c2a39af6318044ae969d46ff7a34ed3f96274adca"}, + {file = "kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b83af57bdddef03c01a9138034c6ff03181a3028d9a1003b301eb1a55e161a3f"}, + {file = "kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf4679a3d71012a7c2bf360e5cd878fbd5e4fcac0896b56393dec239d81529ed"}, + {file = "kiwisolver-1.5.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:41024ed50e44ab1a60d3fe0a9d15a4ccc9f5f2b1d814ff283c8d01134d5b81bc"}, + {file = "kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232"}, + {file = "kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a"}, + {file = "kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737"}, + {file = "kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16"}, + {file = "kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1"}, + {file = "kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a"}, ] [[package]] name = "markupsafe" -version = "3.0.2" +version = "3.0.3" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" groups = ["docs"] files = [ - {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, - {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, + {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, + {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, + {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, + {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, + {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, + {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, + {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, + {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, + {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, ] [[package]] @@ -1176,20 +1285,20 @@ files = [ [[package]] name = "narwhals" -version = "2.3.0" +version = "2.18.1" description = "Extremely lightweight compatibility layer between dataframe libraries" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "narwhals-2.3.0-py3-none-any.whl", hash = "sha256:5507b1a9a9c2b1c55a627fdf6cf722fef2e23498bd14362a332c8848a311c321"}, - {file = "narwhals-2.3.0.tar.gz", hash = "sha256:b66bc4ab7b6746354f60c4b3941e3ce60c066588c35360e2dc6c063489000a16"}, + {file = "narwhals-2.18.1-py3-none-any.whl", hash = "sha256:a0a8bb80205323851338888ba3a12b4f65d352362c8a94be591244faf36504ad"}, + {file = "narwhals-2.18.1.tar.gz", hash = "sha256:652a1fcc9d432bbf114846688884c215f17eb118aa640b7419295d2f910d2a8b"}, ] [package.extras] -cudf = ["cudf (>=24.10.0)"] +cudf = ["cudf-cu12 (>=24.10.0)"] dask = ["dask[dataframe] (>=2024.8)"] -duckdb = ["duckdb (>=1.0)"] +duckdb = ["duckdb (>=1.1)"] ibis = ["ibis-framework (>=6.0.0)", "packaging", "pyarrow-hotfix", "rich"] modin = ["modin"] pandas = ["pandas (>=1.1.3)"] @@ -1197,6 +1306,7 @@ polars = ["polars (>=0.20.4)"] pyarrow = ["pyarrow (>=13.0.0)"] pyspark = ["pyspark (>=3.5.0)"] pyspark-connect = ["pyspark[connect] (>=3.5.0)"] +sql = ["duckdb (>=1.1)", "sqlparse"] sqlframe = ["sqlframe (>=3.22.0,!=3.39.3)"] [[package]] @@ -1247,66 +1357,79 @@ files = [ [[package]] name = "packaging" -version = "25.0" +version = "26.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" groups = ["main", "dev", "docs", "test"] files = [ - {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, - {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, + {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, + {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, ] [[package]] name = "pandas" -version = "2.3.2" +version = "2.3.3" description = "Powerful data structures for data analysis, time series, and statistics" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pandas-2.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:52bc29a946304c360561974c6542d1dd628ddafa69134a7131fdfd6a5d7a1a35"}, - {file = "pandas-2.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:220cc5c35ffaa764dd5bb17cf42df283b5cb7fdf49e10a7b053a06c9cb48ee2b"}, - {file = "pandas-2.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42c05e15111221384019897df20c6fe893b2f697d03c811ee67ec9e0bb5a3424"}, - {file = "pandas-2.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc03acc273c5515ab69f898df99d9d4f12c4d70dbfc24c3acc6203751d0804cf"}, - {file = "pandas-2.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d25c20a03e8870f6339bcf67281b946bd20b86f1a544ebbebb87e66a8d642cba"}, - {file = "pandas-2.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21bb612d148bb5860b7eb2c10faacf1a810799245afd342cf297d7551513fbb6"}, - {file = "pandas-2.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:b62d586eb25cb8cb70a5746a378fc3194cb7f11ea77170d59f889f5dfe3cec7a"}, - {file = "pandas-2.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1333e9c299adcbb68ee89a9bb568fc3f20f9cbb419f1dd5225071e6cddb2a743"}, - {file = "pandas-2.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:76972bcbd7de8e91ad5f0ca884a9f2c477a2125354af624e022c49e5bd0dfff4"}, - {file = "pandas-2.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b98bdd7c456a05eef7cd21fd6b29e3ca243591fe531c62be94a2cc987efb5ac2"}, - {file = "pandas-2.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d81573b3f7db40d020983f78721e9bfc425f411e616ef019a10ebf597aedb2e"}, - {file = "pandas-2.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e190b738675a73b581736cc8ec71ae113d6c3768d0bd18bffa5b9a0927b0b6ea"}, - {file = "pandas-2.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c253828cb08f47488d60f43c5fc95114c771bbfff085da54bfc79cb4f9e3a372"}, - {file = "pandas-2.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:9467697b8083f9667b212633ad6aa4ab32436dcbaf4cd57325debb0ddef2012f"}, - {file = "pandas-2.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fbb977f802156e7a3f829e9d1d5398f6192375a3e2d1a9ee0803e35fe70a2b9"}, - {file = "pandas-2.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b9b52693123dd234b7c985c68b709b0b009f4521000d0525f2b95c22f15944b"}, - {file = "pandas-2.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bd281310d4f412733f319a5bc552f86d62cddc5f51d2e392c8787335c994175"}, - {file = "pandas-2.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96d31a6b4354e3b9b8a2c848af75d31da390657e3ac6f30c05c82068b9ed79b9"}, - {file = "pandas-2.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:df4df0b9d02bb873a106971bb85d448378ef14b86ba96f035f50bbd3688456b4"}, - {file = "pandas-2.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:213a5adf93d020b74327cb2c1b842884dbdd37f895f42dcc2f09d451d949f811"}, - {file = "pandas-2.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c13b81a9347eb8c7548f53fd9a4f08d4dfe996836543f805c987bafa03317ae"}, - {file = "pandas-2.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0c6ecbac99a354a051ef21c5307601093cb9e0f4b1855984a084bfec9302699e"}, - {file = "pandas-2.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c6f048aa0fd080d6a06cc7e7537c09b53be6642d330ac6f54a600c3ace857ee9"}, - {file = "pandas-2.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0064187b80a5be6f2f9c9d6bdde29372468751dfa89f4211a3c5871854cfbf7a"}, - {file = "pandas-2.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ac8c320bded4718b298281339c1a50fb00a6ba78cb2a63521c39bec95b0209b"}, - {file = "pandas-2.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:114c2fe4f4328cf98ce5716d1532f3ab79c5919f95a9cfee81d9140064a2e4d6"}, - {file = "pandas-2.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:48fa91c4dfb3b2b9bfdb5c24cd3567575f4e13f9636810462ffed8925352be5a"}, - {file = "pandas-2.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:12d039facec710f7ba305786837d0225a3444af7bbd9c15c32ca2d40d157ed8b"}, - {file = "pandas-2.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c624b615ce97864eb588779ed4046186f967374185c047070545253a52ab2d57"}, - {file = "pandas-2.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0cee69d583b9b128823d9514171cabb6861e09409af805b54459bd0c821a35c2"}, - {file = "pandas-2.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2319656ed81124982900b4c37f0e0c58c015af9a7bbc62342ba5ad07ace82ba9"}, - {file = "pandas-2.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b37205ad6f00d52f16b6d09f406434ba928c1a1966e2771006a9033c736d30d2"}, - {file = "pandas-2.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:837248b4fc3a9b83b9c6214699a13f069dc13510a6a6d7f9ba33145d2841a012"}, - {file = "pandas-2.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d2c3554bd31b731cd6490d94a28f3abb8dd770634a9e06eb6d2911b9827db370"}, - {file = "pandas-2.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:88080a0ff8a55eac9c84e3ff3c7665b3b5476c6fbc484775ca1910ce1c3e0b87"}, - {file = "pandas-2.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d4a558c7620340a0931828d8065688b3cc5b4c8eb674bcaf33d18ff4a6870b4a"}, - {file = "pandas-2.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45178cf09d1858a1509dc73ec261bf5b25a625a389b65be2e47b559905f0ab6a"}, - {file = "pandas-2.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77cefe00e1b210f9c76c697fedd8fdb8d3dd86563e9c8adc9fa72b90f5e9e4c2"}, - {file = "pandas-2.3.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:13bd629c653856f00c53dc495191baa59bcafbbf54860a46ecc50d3a88421a96"}, - {file = "pandas-2.3.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:36d627906fd44b5fd63c943264e11e96e923f8de77d6016dc2f667b9ad193438"}, - {file = "pandas-2.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:a9d7ec92d71a420185dec44909c32e9a362248c4ae2238234b76d5be37f208cc"}, - {file = "pandas-2.3.2.tar.gz", hash = "sha256:ab7b58f8f82706890924ccdfb5f48002b83d2b5a3845976a9fb705d36c34dcdb"}, + {file = "pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c"}, + {file = "pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a"}, + {file = "pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1"}, + {file = "pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838"}, + {file = "pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250"}, + {file = "pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4"}, + {file = "pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826"}, + {file = "pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523"}, + {file = "pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45"}, + {file = "pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66"}, + {file = "pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b"}, + {file = "pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791"}, + {file = "pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151"}, + {file = "pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c"}, + {file = "pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53"}, + {file = "pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35"}, + {file = "pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908"}, + {file = "pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89"}, + {file = "pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98"}, + {file = "pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084"}, + {file = "pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b"}, + {file = "pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713"}, + {file = "pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8"}, + {file = "pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d"}, + {file = "pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac"}, + {file = "pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c"}, + {file = "pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493"}, + {file = "pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee"}, + {file = "pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5"}, + {file = "pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21"}, + {file = "pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78"}, + {file = "pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110"}, + {file = "pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86"}, + {file = "pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc"}, + {file = "pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0"}, + {file = "pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593"}, + {file = "pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c"}, + {file = "pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b"}, + {file = "pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6"}, + {file = "pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3"}, + {file = "pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5"}, + {file = "pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec"}, + {file = "pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7"}, + {file = "pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450"}, + {file = "pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5"}, + {file = "pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788"}, + {file = "pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87"}, + {file = "pandas-2.3.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c503ba5216814e295f40711470446bc3fd00f0faea8a086cbc688808e26f92a2"}, + {file = "pandas-2.3.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a637c5cdfa04b6d6e2ecedcb81fc52ffb0fd78ce2ebccc9ea964df9f658de8c8"}, + {file = "pandas-2.3.3-cp39-cp39-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:854d00d556406bffe66a4c0802f334c9ad5a96b4f1f868adf036a21b11ef13ff"}, + {file = "pandas-2.3.3-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf1f8a81d04ca90e32a0aceb819d34dbd378a98bf923b6398b9a3ec0bf44de29"}, + {file = "pandas-2.3.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:23ebd657a4d38268c7dfbdf089fbc31ea709d82e4923c5ffd4fbd5747133ce73"}, + {file = "pandas-2.3.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5554c929ccc317d41a5e3d1234f3be588248e61f08a74dd17c9eabb535777dc9"}, + {file = "pandas-2.3.3-cp39-cp39-win_amd64.whl", hash = "sha256:d3e28b3e83862ccf4d85ff19cf8c20b2ae7e503881711ff2d534dc8f761131aa"}, + {file = "pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b"}, ] [package.dependencies] @@ -1346,115 +1469,121 @@ xml = ["lxml (>=4.9.2)"] [[package]] name = "pathspec" -version = "0.12.1" +version = "1.0.4" description = "Utility library for gitignore style pattern matching of file paths." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, - {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, + {file = "pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723"}, + {file = "pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645"}, ] +[package.extras] +hyperscan = ["hyperscan (>=0.7)"] +optional = ["typing-extensions (>=4)"] +re2 = ["google-re2 (>=1.1)"] +tests = ["pytest (>=9)", "typing-extensions (>=4.15)"] + [[package]] name = "pillow" -version = "12.0.0" +version = "12.2.0" description = "Python Imaging Library (fork)" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "pillow-12.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:3adfb466bbc544b926d50fe8f4a4e6abd8c6bffd28a26177594e6e9b2b76572b"}, - {file = "pillow-12.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1ac11e8ea4f611c3c0147424eae514028b5e9077dd99ab91e1bd7bc33ff145e1"}, - {file = "pillow-12.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d49e2314c373f4c2b39446fb1a45ed333c850e09d0c59ac79b72eb3b95397363"}, - {file = "pillow-12.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c7b2a63fd6d5246349f3d3f37b14430d73ee7e8173154461785e43036ffa96ca"}, - {file = "pillow-12.0.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d64317d2587c70324b79861babb9c09f71fbb780bad212018874b2c013d8600e"}, - {file = "pillow-12.0.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d77153e14b709fd8b8af6f66a3afbb9ed6e9fc5ccf0b6b7e1ced7b036a228782"}, - {file = "pillow-12.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:32ed80ea8a90ee3e6fa08c21e2e091bba6eda8eccc83dbc34c95169507a91f10"}, - {file = "pillow-12.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c828a1ae702fc712978bda0320ba1b9893d99be0badf2647f693cc01cf0f04fa"}, - {file = "pillow-12.0.0-cp310-cp310-win32.whl", hash = "sha256:bd87e140e45399c818fac4247880b9ce719e4783d767e030a883a970be632275"}, - {file = "pillow-12.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:455247ac8a4cfb7b9bc45b7e432d10421aea9fc2e74d285ba4072688a74c2e9d"}, - {file = "pillow-12.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:6ace95230bfb7cd79ef66caa064bbe2f2a1e63d93471c3a2e1f1348d9f22d6b7"}, - {file = "pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc"}, - {file = "pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257"}, - {file = "pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642"}, - {file = "pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3"}, - {file = "pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c"}, - {file = "pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227"}, - {file = "pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b"}, - {file = "pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e"}, - {file = "pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739"}, - {file = "pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e"}, - {file = "pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d"}, - {file = "pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371"}, - {file = "pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082"}, - {file = "pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f"}, - {file = "pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d"}, - {file = "pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953"}, - {file = "pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8"}, - {file = "pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79"}, - {file = "pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba"}, - {file = "pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0"}, - {file = "pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a"}, - {file = "pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad"}, - {file = "pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643"}, - {file = "pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4"}, - {file = "pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399"}, - {file = "pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5"}, - {file = "pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b"}, - {file = "pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3"}, - {file = "pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07"}, - {file = "pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e"}, - {file = "pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344"}, - {file = "pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27"}, - {file = "pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79"}, - {file = "pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098"}, - {file = "pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905"}, - {file = "pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a"}, - {file = "pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3"}, - {file = "pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced"}, - {file = "pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b"}, - {file = "pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d"}, - {file = "pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a"}, - {file = "pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe"}, - {file = "pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee"}, - {file = "pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef"}, - {file = "pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9"}, - {file = "pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b"}, - {file = "pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47"}, - {file = "pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9"}, - {file = "pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2"}, - {file = "pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a"}, - {file = "pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b"}, - {file = "pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad"}, - {file = "pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01"}, - {file = "pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c"}, - {file = "pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e"}, - {file = "pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e"}, - {file = "pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9"}, - {file = "pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab"}, - {file = "pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b"}, - {file = "pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b"}, - {file = "pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0"}, - {file = "pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6"}, - {file = "pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6"}, - {file = "pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1"}, - {file = "pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e"}, - {file = "pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca"}, - {file = "pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925"}, - {file = "pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8"}, - {file = "pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4"}, - {file = "pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52"}, - {file = "pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a"}, - {file = "pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7"}, - {file = "pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8"}, - {file = "pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a"}, - {file = "pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197"}, - {file = "pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c"}, - {file = "pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e"}, - {file = "pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76"}, - {file = "pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5"}, - {file = "pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353"}, + {file = "pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f"}, + {file = "pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97"}, + {file = "pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff"}, + {file = "pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec"}, + {file = "pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136"}, + {file = "pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c"}, + {file = "pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3"}, + {file = "pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa"}, + {file = "pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032"}, + {file = "pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5"}, + {file = "pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024"}, + {file = "pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab"}, + {file = "pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65"}, + {file = "pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7"}, + {file = "pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e"}, + {file = "pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705"}, + {file = "pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176"}, + {file = "pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b"}, + {file = "pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909"}, + {file = "pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808"}, + {file = "pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60"}, + {file = "pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe"}, + {file = "pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5"}, + {file = "pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421"}, + {file = "pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987"}, + {file = "pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76"}, + {file = "pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005"}, + {file = "pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780"}, + {file = "pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5"}, + {file = "pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5"}, + {file = "pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940"}, + {file = "pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5"}, + {file = "pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414"}, + {file = "pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c"}, + {file = "pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2"}, + {file = "pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c"}, + {file = "pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795"}, + {file = "pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f"}, + {file = "pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed"}, + {file = "pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9"}, + {file = "pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed"}, + {file = "pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3"}, + {file = "pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9"}, + {file = "pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795"}, + {file = "pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e"}, + {file = "pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b"}, + {file = "pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06"}, + {file = "pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b"}, + {file = "pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f"}, + {file = "pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612"}, + {file = "pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c"}, + {file = "pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea"}, + {file = "pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4"}, + {file = "pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4"}, + {file = "pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea"}, + {file = "pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24"}, + {file = "pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98"}, + {file = "pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453"}, + {file = "pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8"}, + {file = "pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b"}, + {file = "pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295"}, + {file = "pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed"}, + {file = "pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae"}, + {file = "pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601"}, + {file = "pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be"}, + {file = "pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f"}, + {file = "pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286"}, + {file = "pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50"}, + {file = "pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104"}, + {file = "pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7"}, + {file = "pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150"}, + {file = "pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1"}, + {file = "pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463"}, + {file = "pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3"}, + {file = "pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166"}, + {file = "pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe"}, + {file = "pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd"}, + {file = "pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e"}, + {file = "pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06"}, + {file = "pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43"}, + {file = "pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354"}, + {file = "pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1"}, + {file = "pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e"}, + {file = "pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5"}, ] [package.extras] @@ -1467,31 +1596,26 @@ xmp = ["defusedxml"] [[package]] name = "platformdirs" -version = "4.4.0" +version = "4.9.4" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev", "test"] files = [ - {file = "platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85"}, - {file = "platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf"}, + {file = "platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868"}, + {file = "platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934"}, ] -[package.extras] -docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] -type = ["mypy (>=1.14.1)"] - [[package]] name = "plotly" -version = "6.3.0" +version = "6.6.0" description = "An open-source interactive data visualization library for Python" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "plotly-6.3.0-py3-none-any.whl", hash = "sha256:7ad806edce9d3cdd882eaebaf97c0c9e252043ed1ed3d382c3e3520ec07806d4"}, - {file = "plotly-6.3.0.tar.gz", hash = "sha256:8840a184d18ccae0f9189c2b9a2943923fd5cae7717b723f36eef78f444e5a73"}, + {file = "plotly-6.6.0-py3-none-any.whl", hash = "sha256:8d6daf0f87412e0c0bfe72e809d615217ab57cc715899a1e5145135a7800d1d0"}, + {file = "plotly-6.6.0.tar.gz", hash = "sha256:b897f15f3b02028d69f755f236be890ba950d0a42d7dfc619b44e2d8cea8748c"}, ] [package.dependencies] @@ -1504,7 +1628,7 @@ dev-build = ["build", "jupyter", "plotly[dev-core]"] dev-core = ["pytest", "requests", "ruff (==0.11.12)"] dev-optional = ["anywidget", "colorcet", "fiona (<=1.9.6) ; python_version <= \"3.8\"", "geopandas", "inflect", "numpy", "orjson", "pandas", "pdfrw", "pillow", "plotly-geo", "plotly[dev-build]", "plotly[kaleido]", "polars[timezone]", "pyarrow", "pyshp", "pytz", "scikit-image", "scipy", "shapely", "statsmodels", "vaex ; python_version <= \"3.9\"", "xarray"] express = ["numpy"] -kaleido = ["kaleido (>=1.0.0)"] +kaleido = ["kaleido (>=1.1.0)"] [[package]] name = "pluggy" @@ -1536,15 +1660,15 @@ files = [ [[package]] name = "pycparser" -version = "2.23" +version = "3.0" description = "C parser in Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["main"] markers = "implementation_name == \"pypy\"" files = [ - {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, - {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, + {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, + {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, ] [[package]] @@ -1561,14 +1685,14 @@ files = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" description = "Pygments is a syntax highlighting package written in Python." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["docs", "test"] files = [ - {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, - {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, + {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, + {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, ] [package.extras] @@ -1576,14 +1700,14 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pylint" -version = "3.3.8" +version = "3.3.9" description = "python code static checker" optional = false python-versions = ">=3.9.0" groups = ["dev"] files = [ - {file = "pylint-3.3.8-py3-none-any.whl", hash = "sha256:7ef94aa692a600e82fabdd17102b73fc226758218c97473c7ad67bd4cb905d83"}, - {file = "pylint-3.3.8.tar.gz", hash = "sha256:26698de19941363037e2937d3db9ed94fb3303fdadf7d98847875345a8bb6b05"}, + {file = "pylint-3.3.9-py3-none-any.whl", hash = "sha256:01f9b0462c7730f94786c283f3e52a1fbdf0494bbe0971a78d7277ef46a751e7"}, + {file = "pylint-3.3.9.tar.gz", hash = "sha256:d312737d7b25ccf6b01cc4ac629b5dcd14a0fcf3ec392735ac70f137a9d5f83a"}, ] [package.dependencies] @@ -1606,14 +1730,14 @@ testutils = ["gitpython (>3)"] [[package]] name = "pyparsing" -version = "3.2.5" +version = "3.3.2" description = "pyparsing - Classes and methods to define and execute parsing grammars" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pyparsing-3.2.5-py3-none-any.whl", hash = "sha256:e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e"}, - {file = "pyparsing-3.2.5.tar.gz", hash = "sha256:2df8d5b7b2802ef88e8d016a2eb9c7aeaa923529cd251ed0fe4608275d4105b6"}, + {file = "pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d"}, + {file = "pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc"}, ] [package.extras] @@ -1621,34 +1745,34 @@ diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pyproject-api" -version = "1.9.1" +version = "1.10.0" description = "API to interact with the python pyproject.toml based projects" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["test"] files = [ - {file = "pyproject_api-1.9.1-py3-none-any.whl", hash = "sha256:7d6238d92f8962773dd75b5f0c4a6a27cce092a14b623b811dba656f3b628948"}, - {file = "pyproject_api-1.9.1.tar.gz", hash = "sha256:43c9918f49daab37e302038fc1aed54a8c7a91a9fa935d00b9a485f37e0f5335"}, + {file = "pyproject_api-1.10.0-py3-none-any.whl", hash = "sha256:8757c41a79c0f4ab71b99abed52b97ecf66bd20b04fa59da43b5840bac105a09"}, + {file = "pyproject_api-1.10.0.tar.gz", hash = "sha256:40c6f2d82eebdc4afee61c773ed208c04c19db4c4a60d97f8d7be3ebc0bbb330"}, ] [package.dependencies] packaging = ">=25" -tomli = {version = ">=2.2.1", markers = "python_version < \"3.11\""} +tomli = {version = ">=2.3", markers = "python_version < \"3.11\""} [package.extras] -docs = ["furo (>=2024.8.6)", "sphinx-autodoc-typehints (>=3.2)"] -testing = ["covdefaults (>=2.3)", "pytest (>=8.3.5)", "pytest-cov (>=6.1.1)", "pytest-mock (>=3.14)", "setuptools (>=80.3.1)"] +docs = ["furo (>=2025.9.25)", "sphinx-autodoc-typehints (>=3.5.1)"] +testing = ["covdefaults (>=2.3)", "pytest (>=8.4.2)", "pytest-cov (>=7)", "pytest-mock (>=3.15.1)", "setuptools (>=80.9)"] [[package]] name = "pytest" -version = "8.4.1" +version = "8.4.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.9" groups = ["test"] files = [ - {file = "pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7"}, - {file = "pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c"}, + {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, + {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, ] [package.dependencies] @@ -1678,16 +1802,36 @@ files = [ [package.dependencies] six = ">=1.5" +[[package]] +name = "python-discovery" +version = "1.2.1" +description = "Python interpreter discovery" +optional = false +python-versions = ">=3.8" +groups = ["test"] +files = [ + {file = "python_discovery-1.2.1-py3-none-any.whl", hash = "sha256:b6a957b24c1cd79252484d3566d1b49527581d46e789aaf43181005e56201502"}, + {file = "python_discovery-1.2.1.tar.gz", hash = "sha256:180c4d114bff1c32462537eac5d6a332b768242b76b69c0259c7d14b1b680c9e"}, +] + +[package.dependencies] +filelock = ">=3.15.4" +platformdirs = ">=4.3.6,<5" + +[package.extras] +docs = ["furo (>=2025.12.19)", "sphinx (>=9.1)", "sphinx-autodoc-typehints (>=3.6.3)", "sphinxcontrib-mermaid (>=2)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.5.4)", "pytest (>=8.3.5)", "pytest-mock (>=3.14)", "setuptools (>=75.1)"] + [[package]] name = "pytz" -version = "2025.2" +version = "2026.1.post1" description = "World timezone definitions, modern and historical" optional = false python-versions = "*" groups = ["main"] files = [ - {file = "pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00"}, - {file = "pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3"}, + {file = "pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a"}, + {file = "pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1"}, ] [[package]] @@ -1880,26 +2024,26 @@ cffi = {version = "*", markers = "implementation_name == \"pypy\""} [[package]] name = "quantiphy" -version = "2.20" +version = "2.21" description = "physical quantities (numbers with units)" optional = false python-versions = ">=3.6" groups = ["main"] files = [ - {file = "quantiphy-2.20-py3-none-any.whl", hash = "sha256:afdf5f9d1cc87359bd7daf19dc1cb23808eb2e264d9395b36ca6527fb4d71b3a"}, - {file = "quantiphy-2.20.tar.gz", hash = "sha256:ba5375ac55c3b90077a793588dd5a88aaf81b2c3b0fc9c9359513ac39f6ed84d"}, + {file = "quantiphy-2.21-py3-none-any.whl", hash = "sha256:7d145d89a908de38d63c8dc0a8883bc668c85d84ed37fe6535d26cf7c077f69f"}, + {file = "quantiphy-2.21.tar.gz", hash = "sha256:bf8d06ffa7150f69a5c7e3fb4a7a0a535109df85e7c0ab0f39fb317c5c9cafe0"}, ] [[package]] name = "referencing" -version = "0.36.2" +version = "0.37.0" description = "JSON Referencing + Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0"}, - {file = "referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa"}, + {file = "referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231"}, + {file = "referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8"}, ] [package.dependencies] @@ -1909,189 +2053,149 @@ typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""} [[package]] name = "requests" -version = "2.32.5" +version = "2.33.1" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["docs"] files = [ - {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, - {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, + {file = "requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"}, + {file = "requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517"}, ] [package.dependencies] -certifi = ">=2017.4.17" +certifi = ">=2023.5.7" charset_normalizer = ">=2,<4" idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" +urllib3 = ">=1.26,<3" [package.extras] socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] [[package]] name = "rpds-py" -version = "0.27.1" +version = "0.30.0" description = "Python bindings to Rust's persistent data structures (rpds)" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "rpds_py-0.27.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:68afeec26d42ab3b47e541b272166a0b4400313946871cba3ed3a4fc0cab1cef"}, - {file = "rpds_py-0.27.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:74e5b2f7bb6fa38b1b10546d27acbacf2a022a8b5543efb06cfebc72a59c85be"}, - {file = "rpds_py-0.27.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9024de74731df54546fab0bfbcdb49fae19159ecaecfc8f37c18d2c7e2c0bd61"}, - {file = "rpds_py-0.27.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:31d3ebadefcd73b73928ed0b2fd696f7fefda8629229f81929ac9c1854d0cffb"}, - {file = "rpds_py-0.27.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2e7f8f169d775dd9092a1743768d771f1d1300453ddfe6325ae3ab5332b4657"}, - {file = "rpds_py-0.27.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d905d16f77eb6ab2e324e09bfa277b4c8e5e6b8a78a3e7ff8f3cdf773b4c013"}, - {file = "rpds_py-0.27.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50c946f048209e6362e22576baea09193809f87687a95a8db24e5fbdb307b93a"}, - {file = "rpds_py-0.27.1-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:3deab27804d65cd8289eb814c2c0e807c4b9d9916c9225e363cb0cf875eb67c1"}, - {file = "rpds_py-0.27.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8b61097f7488de4be8244c89915da8ed212832ccf1e7c7753a25a394bf9b1f10"}, - {file = "rpds_py-0.27.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8a3f29aba6e2d7d90528d3c792555a93497fe6538aa65eb675b44505be747808"}, - {file = "rpds_py-0.27.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd6cd0485b7d347304067153a6dc1d73f7d4fd995a396ef32a24d24b8ac63ac8"}, - {file = "rpds_py-0.27.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6f4461bf931108c9fa226ffb0e257c1b18dc2d44cd72b125bec50ee0ab1248a9"}, - {file = "rpds_py-0.27.1-cp310-cp310-win32.whl", hash = "sha256:ee5422d7fb21f6a00c1901bf6559c49fee13a5159d0288320737bbf6585bd3e4"}, - {file = "rpds_py-0.27.1-cp310-cp310-win_amd64.whl", hash = "sha256:3e039aabf6d5f83c745d5f9a0a381d031e9ed871967c0a5c38d201aca41f3ba1"}, - {file = "rpds_py-0.27.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:be898f271f851f68b318872ce6ebebbc62f303b654e43bf72683dbdc25b7c881"}, - {file = "rpds_py-0.27.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:62ac3d4e3e07b58ee0ddecd71d6ce3b1637de2d373501412df395a0ec5f9beb5"}, - {file = "rpds_py-0.27.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4708c5c0ceb2d034f9991623631d3d23cb16e65c83736ea020cdbe28d57c0a0e"}, - {file = "rpds_py-0.27.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:abfa1171a9952d2e0002aba2ad3780820b00cc3d9c98c6630f2e93271501f66c"}, - {file = "rpds_py-0.27.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b507d19f817ebaca79574b16eb2ae412e5c0835542c93fe9983f1e432aca195"}, - {file = "rpds_py-0.27.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:168b025f8fd8d8d10957405f3fdcef3dc20f5982d398f90851f4abc58c566c52"}, - {file = "rpds_py-0.27.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb56c6210ef77caa58e16e8c17d35c63fe3f5b60fd9ba9d424470c3400bcf9ed"}, - {file = "rpds_py-0.27.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:d252f2d8ca0195faa707f8eb9368955760880b2b42a8ee16d382bf5dd807f89a"}, - {file = "rpds_py-0.27.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6e5e54da1e74b91dbc7996b56640f79b195d5925c2b78efaa8c5d53e1d88edde"}, - {file = "rpds_py-0.27.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ffce0481cc6e95e5b3f0a47ee17ffbd234399e6d532f394c8dce320c3b089c21"}, - {file = "rpds_py-0.27.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a205fdfe55c90c2cd8e540ca9ceba65cbe6629b443bc05db1f590a3db8189ff9"}, - {file = "rpds_py-0.27.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:689fb5200a749db0415b092972e8eba85847c23885c8543a8b0f5c009b1a5948"}, - {file = "rpds_py-0.27.1-cp311-cp311-win32.whl", hash = "sha256:3182af66048c00a075010bc7f4860f33913528a4b6fc09094a6e7598e462fe39"}, - {file = "rpds_py-0.27.1-cp311-cp311-win_amd64.whl", hash = "sha256:b4938466c6b257b2f5c4ff98acd8128ec36b5059e5c8f8372d79316b1c36bb15"}, - {file = "rpds_py-0.27.1-cp311-cp311-win_arm64.whl", hash = "sha256:2f57af9b4d0793e53266ee4325535a31ba48e2f875da81a9177c9926dfa60746"}, - {file = "rpds_py-0.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ae2775c1973e3c30316892737b91f9283f9908e3cc7625b9331271eaaed7dc90"}, - {file = "rpds_py-0.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2643400120f55c8a96f7c9d858f7be0c88d383cd4653ae2cf0d0c88f668073e5"}, - {file = "rpds_py-0.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16323f674c089b0360674a4abd28d5042947d54ba620f72514d69be4ff64845e"}, - {file = "rpds_py-0.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a1f4814b65eacac94a00fc9a526e3fdafd78e439469644032032d0d63de4881"}, - {file = "rpds_py-0.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ba32c16b064267b22f1850a34051121d423b6f7338a12b9459550eb2096e7ec"}, - {file = "rpds_py-0.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5c20f33fd10485b80f65e800bbe5f6785af510b9f4056c5a3c612ebc83ba6cb"}, - {file = "rpds_py-0.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:466bfe65bd932da36ff279ddd92de56b042f2266d752719beb97b08526268ec5"}, - {file = "rpds_py-0.27.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:41e532bbdcb57c92ba3be62c42e9f096431b4cf478da9bc3bc6ce5c38ab7ba7a"}, - {file = "rpds_py-0.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f149826d742b406579466283769a8ea448eed82a789af0ed17b0cd5770433444"}, - {file = "rpds_py-0.27.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80c60cfb5310677bd67cb1e85a1e8eb52e12529545441b43e6f14d90b878775a"}, - {file = "rpds_py-0.27.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7ee6521b9baf06085f62ba9c7a3e5becffbc32480d2f1b351559c001c38ce4c1"}, - {file = "rpds_py-0.27.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a512c8263249a9d68cac08b05dd59d2b3f2061d99b322813cbcc14c3c7421998"}, - {file = "rpds_py-0.27.1-cp312-cp312-win32.whl", hash = "sha256:819064fa048ba01b6dadc5116f3ac48610435ac9a0058bbde98e569f9e785c39"}, - {file = "rpds_py-0.27.1-cp312-cp312-win_amd64.whl", hash = "sha256:d9199717881f13c32c4046a15f024971a3b78ad4ea029e8da6b86e5aa9cf4594"}, - {file = "rpds_py-0.27.1-cp312-cp312-win_arm64.whl", hash = "sha256:33aa65b97826a0e885ef6e278fbd934e98cdcfed80b63946025f01e2f5b29502"}, - {file = "rpds_py-0.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e4b9fcfbc021633863a37e92571d6f91851fa656f0180246e84cbd8b3f6b329b"}, - {file = "rpds_py-0.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1441811a96eadca93c517d08df75de45e5ffe68aa3089924f963c782c4b898cf"}, - {file = "rpds_py-0.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55266dafa22e672f5a4f65019015f90336ed31c6383bd53f5e7826d21a0e0b83"}, - {file = "rpds_py-0.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d78827d7ac08627ea2c8e02c9e5b41180ea5ea1f747e9db0915e3adf36b62dcf"}, - {file = "rpds_py-0.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae92443798a40a92dc5f0b01d8a7c93adde0c4dc965310a29ae7c64d72b9fad2"}, - {file = "rpds_py-0.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c46c9dd2403b66a2a3b9720ec4b74d4ab49d4fabf9f03dfdce2d42af913fe8d0"}, - {file = "rpds_py-0.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2efe4eb1d01b7f5f1939f4ef30ecea6c6b3521eec451fb93191bf84b2a522418"}, - {file = "rpds_py-0.27.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:15d3b4d83582d10c601f481eca29c3f138d44c92187d197aff663a269197c02d"}, - {file = "rpds_py-0.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4ed2e16abbc982a169d30d1a420274a709949e2cbdef119fe2ec9d870b42f274"}, - {file = "rpds_py-0.27.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a75f305c9b013289121ec0f1181931975df78738cdf650093e6b86d74aa7d8dd"}, - {file = "rpds_py-0.27.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:67ce7620704745881a3d4b0ada80ab4d99df390838839921f99e63c474f82cf2"}, - {file = "rpds_py-0.27.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d992ac10eb86d9b6f369647b6a3f412fc0075cfd5d799530e84d335e440a002"}, - {file = "rpds_py-0.27.1-cp313-cp313-win32.whl", hash = "sha256:4f75e4bd8ab8db624e02c8e2fc4063021b58becdbe6df793a8111d9343aec1e3"}, - {file = "rpds_py-0.27.1-cp313-cp313-win_amd64.whl", hash = "sha256:f9025faafc62ed0b75a53e541895ca272815bec18abe2249ff6501c8f2e12b83"}, - {file = "rpds_py-0.27.1-cp313-cp313-win_arm64.whl", hash = "sha256:ed10dc32829e7d222b7d3b93136d25a406ba9788f6a7ebf6809092da1f4d279d"}, - {file = "rpds_py-0.27.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:92022bbbad0d4426e616815b16bc4127f83c9a74940e1ccf3cfe0b387aba0228"}, - {file = "rpds_py-0.27.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:47162fdab9407ec3f160805ac3e154df042e577dd53341745fc7fb3f625e6d92"}, - {file = "rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb89bec23fddc489e5d78b550a7b773557c9ab58b7946154a10a6f7a214a48b2"}, - {file = "rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e48af21883ded2b3e9eb48cb7880ad8598b31ab752ff3be6457001d78f416723"}, - {file = "rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f5b7bd8e219ed50299e58551a410b64daafb5017d54bbe822e003856f06a802"}, - {file = "rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08f1e20bccf73b08d12d804d6e1c22ca5530e71659e6673bce31a6bb71c1e73f"}, - {file = "rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dc5dceeaefcc96dc192e3a80bbe1d6c410c469e97bdd47494a7d930987f18b2"}, - {file = "rpds_py-0.27.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d76f9cc8665acdc0c9177043746775aa7babbf479b5520b78ae4002d889f5c21"}, - {file = "rpds_py-0.27.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:134fae0e36022edad8290a6661edf40c023562964efea0cc0ec7f5d392d2aaef"}, - {file = "rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb11a4f1b2b63337cfd3b4d110af778a59aae51c81d195768e353d8b52f88081"}, - {file = "rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:13e608ac9f50a0ed4faec0e90ece76ae33b34c0e8656e3dceb9a7db994c692cd"}, - {file = "rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dd2135527aa40f061350c3f8f89da2644de26cd73e4de458e79606384f4f68e7"}, - {file = "rpds_py-0.27.1-cp313-cp313t-win32.whl", hash = "sha256:3020724ade63fe320a972e2ffd93b5623227e684315adce194941167fee02688"}, - {file = "rpds_py-0.27.1-cp313-cp313t-win_amd64.whl", hash = "sha256:8ee50c3e41739886606388ba3ab3ee2aae9f35fb23f833091833255a31740797"}, - {file = "rpds_py-0.27.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:acb9aafccaae278f449d9c713b64a9e68662e7799dbd5859e2c6b3c67b56d334"}, - {file = "rpds_py-0.27.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b7fb801aa7f845ddf601c49630deeeccde7ce10065561d92729bfe81bd21fb33"}, - {file = "rpds_py-0.27.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe0dd05afb46597b9a2e11c351e5e4283c741237e7f617ffb3252780cca9336a"}, - {file = "rpds_py-0.27.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b6dfb0e058adb12d8b1d1b25f686e94ffa65d9995a5157afe99743bf7369d62b"}, - {file = "rpds_py-0.27.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed090ccd235f6fa8bb5861684567f0a83e04f52dfc2e5c05f2e4b1309fcf85e7"}, - {file = "rpds_py-0.27.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf876e79763eecf3e7356f157540d6a093cef395b65514f17a356f62af6cc136"}, - {file = "rpds_py-0.27.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12ed005216a51b1d6e2b02a7bd31885fe317e45897de81d86dcce7d74618ffff"}, - {file = "rpds_py-0.27.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ee4308f409a40e50593c7e3bb8cbe0b4d4c66d1674a316324f0c2f5383b486f9"}, - {file = "rpds_py-0.27.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b08d152555acf1f455154d498ca855618c1378ec810646fcd7c76416ac6dc60"}, - {file = "rpds_py-0.27.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dce51c828941973a5684d458214d3a36fcd28da3e1875d659388f4f9f12cc33e"}, - {file = "rpds_py-0.27.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c1476d6f29eb81aa4151c9a31219b03f1f798dc43d8af1250a870735516a1212"}, - {file = "rpds_py-0.27.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3ce0cac322b0d69b63c9cdb895ee1b65805ec9ffad37639f291dd79467bee675"}, - {file = "rpds_py-0.27.1-cp314-cp314-win32.whl", hash = "sha256:dfbfac137d2a3d0725758cd141f878bf4329ba25e34979797c89474a89a8a3a3"}, - {file = "rpds_py-0.27.1-cp314-cp314-win_amd64.whl", hash = "sha256:a6e57b0abfe7cc513450fcf529eb486b6e4d3f8aee83e92eb5f1ef848218d456"}, - {file = "rpds_py-0.27.1-cp314-cp314-win_arm64.whl", hash = "sha256:faf8d146f3d476abfee026c4ae3bdd9ca14236ae4e4c310cbd1cf75ba33d24a3"}, - {file = "rpds_py-0.27.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ba81d2b56b6d4911ce735aad0a1d4495e808b8ee4dc58715998741a26874e7c2"}, - {file = "rpds_py-0.27.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:84f7d509870098de0e864cad0102711c1e24e9b1a50ee713b65928adb22269e4"}, - {file = "rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e960fc78fecd1100539f14132425e1d5fe44ecb9239f8f27f079962021523e"}, - {file = "rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62f85b665cedab1a503747617393573995dac4600ff51869d69ad2f39eb5e817"}, - {file = "rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fed467af29776f6556250c9ed85ea5a4dd121ab56a5f8b206e3e7a4c551e48ec"}, - {file = "rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2729615f9d430af0ae6b36cf042cb55c0936408d543fb691e1a9e36648fd35a"}, - {file = "rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b207d881a9aef7ba753d69c123a35d96ca7cb808056998f6b9e8747321f03b8"}, - {file = "rpds_py-0.27.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:639fd5efec029f99b79ae47e5d7e00ad8a773da899b6309f6786ecaf22948c48"}, - {file = "rpds_py-0.27.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fecc80cb2a90e28af8a9b366edacf33d7a91cbfe4c2c4544ea1246e949cfebeb"}, - {file = "rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42a89282d711711d0a62d6f57d81aa43a1368686c45bc1c46b7f079d55692734"}, - {file = "rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:cf9931f14223de59551ab9d38ed18d92f14f055a5f78c1d8ad6493f735021bbb"}, - {file = "rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f39f58a27cc6e59f432b568ed8429c7e1641324fbe38131de852cd77b2d534b0"}, - {file = "rpds_py-0.27.1-cp314-cp314t-win32.whl", hash = "sha256:d5fa0ee122dc09e23607a28e6d7b150da16c662e66409bbe85230e4c85bb528a"}, - {file = "rpds_py-0.27.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6567d2bb951e21232c2f660c24cf3470bb96de56cdcb3f071a83feeaff8a2772"}, - {file = "rpds_py-0.27.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c918c65ec2e42c2a78d19f18c553d77319119bf43aa9e2edf7fb78d624355527"}, - {file = "rpds_py-0.27.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1fea2b1a922c47c51fd07d656324531adc787e415c8b116530a1d29c0516c62d"}, - {file = "rpds_py-0.27.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbf94c58e8e0cd6b6f38d8de67acae41b3a515c26169366ab58bdca4a6883bb8"}, - {file = "rpds_py-0.27.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c2a8fed130ce946d5c585eddc7c8eeef0051f58ac80a8ee43bd17835c144c2cc"}, - {file = "rpds_py-0.27.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:037a2361db72ee98d829bc2c5b7cc55598ae0a5e0ec1823a56ea99374cfd73c1"}, - {file = "rpds_py-0.27.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5281ed1cc1d49882f9997981c88df1a22e140ab41df19071222f7e5fc4e72125"}, - {file = "rpds_py-0.27.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fd50659a069c15eef8aa3d64bbef0d69fd27bb4a50c9ab4f17f83a16cbf8905"}, - {file = "rpds_py-0.27.1-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:c4b676c4ae3921649a15d28ed10025548e9b561ded473aa413af749503c6737e"}, - {file = "rpds_py-0.27.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:079bc583a26db831a985c5257797b2b5d3affb0386e7ff886256762f82113b5e"}, - {file = "rpds_py-0.27.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4e44099bd522cba71a2c6b97f68e19f40e7d85399de899d66cdb67b32d7cb786"}, - {file = "rpds_py-0.27.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e202e6d4188e53c6661af813b46c37ca2c45e497fc558bacc1a7630ec2695aec"}, - {file = "rpds_py-0.27.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:f41f814b8eaa48768d1bb551591f6ba45f87ac76899453e8ccd41dba1289b04b"}, - {file = "rpds_py-0.27.1-cp39-cp39-win32.whl", hash = "sha256:9e71f5a087ead99563c11fdaceee83ee982fd39cf67601f4fd66cb386336ee52"}, - {file = "rpds_py-0.27.1-cp39-cp39-win_amd64.whl", hash = "sha256:71108900c9c3c8590697244b9519017a400d9ba26a36c48381b3f64743a44aab"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7ba22cb9693df986033b91ae1d7a979bc399237d45fccf875b76f62bb9e52ddf"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5b640501be9288c77738b5492b3fd3abc4ba95c50c2e41273c8a1459f08298d3"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb08b65b93e0c6dd70aac7f7890a9c0938d5ec71d5cb32d45cf844fb8ae47636"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d7ff07d696a7a38152ebdb8212ca9e5baab56656749f3d6004b34ab726b550b8"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fb7c72262deae25366e3b6c0c0ba46007967aea15d1eea746e44ddba8ec58dcc"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b002cab05d6339716b03a4a3a2ce26737f6231d7b523f339fa061d53368c9d8"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23f6b69d1c26c4704fec01311963a41d7de3ee0570a84ebde4d544e5a1859ffc"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:530064db9146b247351f2a0250b8f00b289accea4596a033e94be2389977de71"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b90b0496570bd6b0321724a330d8b545827c4df2034b6ddfc5f5275f55da2ad"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:879b0e14a2da6a1102a3fc8af580fc1ead37e6d6692a781bd8c83da37429b5ab"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:0d807710df3b5faa66c731afa162ea29717ab3be17bdc15f90f2d9f183da4059"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:3adc388fc3afb6540aec081fa59e6e0d3908722771aa1e37ffe22b220a436f0b"}, - {file = "rpds_py-0.27.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c796c0c1cc68cb08b0284db4229f5af76168172670c74908fdbd4b7d7f515819"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cdfe4bb2f9fe7458b7453ad3c33e726d6d1c7c0a72960bcc23800d77384e42df"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8fabb8fd848a5f75a2324e4a84501ee3a5e3c78d8603f83475441866e60b94a3"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eda8719d598f2f7f3e0f885cba8646644b55a187762bec091fa14a2b819746a9"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c64d07e95606ec402a0a1c511fe003873fa6af630bda59bac77fac8b4318ebc"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:93a2ed40de81bcff59aabebb626562d48332f3d028ca2036f1d23cbb52750be4"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:387ce8c44ae94e0ec50532d9cb0edce17311024c9794eb196b90e1058aadeb66"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaf94f812c95b5e60ebaf8bfb1898a7d7cb9c1af5744d4a67fa47796e0465d4e"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:4848ca84d6ded9b58e474dfdbad4b8bfb450344c0551ddc8d958bf4b36aa837c"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2bde09cbcf2248b73c7c323be49b280180ff39fadcfe04e7b6f54a678d02a7cf"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:94c44ee01fd21c9058f124d2d4f0c9dc7634bec93cd4b38eefc385dabe71acbf"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:df8b74962e35c9249425d90144e721eed198e6555a0e22a563d29fe4486b51f6"}, - {file = "rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:dc23e6820e3b40847e2f4a7726462ba0cf53089512abe9ee16318c366494c17a"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:aa8933159edc50be265ed22b401125c9eebff3171f570258854dbce3ecd55475"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:a50431bf02583e21bf273c71b89d710e7a710ad5e39c725b14e685610555926f"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78af06ddc7fe5cc0e967085a9115accee665fb912c22a3f54bad70cc65b05fe6"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:70d0738ef8fee13c003b100c2fbd667ec4f133468109b3472d249231108283a3"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2f6fd8a1cea5bbe599b6e78a6e5ee08db434fc8ffea51ff201c8765679698b3"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8177002868d1426305bb5de1e138161c2ec9eb2d939be38291d7c431c4712df8"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:008b839781d6c9bf3b6a8984d1d8e56f0ec46dc56df61fd669c49b58ae800400"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:a55b9132bb1ade6c734ddd2759c8dc132aa63687d259e725221f106b83a0e485"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a46fdec0083a26415f11d5f236b79fa1291c32aaa4a17684d82f7017a1f818b1"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:8a63b640a7845f2bdd232eb0d0a4a2dd939bcdd6c57e6bb134526487f3160ec5"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:7e32721e5d4922deaaf963469d795d5bde6093207c52fec719bd22e5d1bedbc4"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:2c426b99a068601b5f4623573df7a7c3d72e87533a2dd2253353a03e7502566c"}, - {file = "rpds_py-0.27.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:4fc9b7fe29478824361ead6e14e4f5aed570d477e06088826537e202d25fe859"}, - {file = "rpds_py-0.27.1.tar.gz", hash = "sha256:26a1c73171d10b7acccbded82bf6a586ab8203601e565badc74bbbf8bc5a10f8"}, + {file = "rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288"}, + {file = "rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139"}, + {file = "rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464"}, + {file = "rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169"}, + {file = "rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425"}, + {file = "rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85"}, + {file = "rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c"}, + {file = "rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825"}, + {file = "rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229"}, + {file = "rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad"}, + {file = "rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394"}, + {file = "rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf"}, + {file = "rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b"}, + {file = "rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e"}, + {file = "rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2"}, + {file = "rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95"}, + {file = "rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d"}, + {file = "rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15"}, + {file = "rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1"}, + {file = "rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a"}, + {file = "rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27"}, + {file = "rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6"}, + {file = "rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d"}, + {file = "rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0"}, + {file = "rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53"}, + {file = "rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed"}, + {file = "rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950"}, + {file = "rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6"}, + {file = "rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb"}, + {file = "rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40"}, + {file = "rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0"}, + {file = "rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e"}, + {file = "rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84"}, ] [[package]] @@ -2160,14 +2264,14 @@ test = ["Cython", "array-api-strict (>=2.0,<2.1.1)", "asv", "gmpy2", "hypothesis [[package]] name = "sigmf" -version = "1.2.10" +version = "1.7.2" description = "Easily interact with Signal Metadata Format (SigMF) recordings." optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "sigmf-1.2.10-py3-none-any.whl", hash = "sha256:a9a7f7d0d48c350a78fc48ab14baa0fb7fa2441855467f3593d4c591d2018c11"}, - {file = "sigmf-1.2.10.tar.gz", hash = "sha256:0301a312abb9d34e090798a67886ea0aa2bf796242d856e49c8707fd3d3c9255"}, + {file = "sigmf-1.7.2-py3-none-any.whl", hash = "sha256:6599b95e8bd3ac2c568b8ec46c312a77b80868cbda79d729234f396d2194d3d8"}, + {file = "sigmf-1.7.2.tar.gz", hash = "sha256:5f80f7127539358c7528ccf26e0ac5b3c268ecaeb69a921542e8ff71d0c85346"}, ] [package.dependencies] @@ -2175,7 +2279,6 @@ jsonschema = "*" numpy = "*" [package.extras] -apps = ["scipy"] test = ["hypothesis", "pylint", "pytest", "pytest-cov"] [[package]] @@ -2190,18 +2293,6 @@ files = [ {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, ] -[[package]] -name = "sniffio" -version = "1.3.1" -description = "Sniff out which async library your code is running under" -optional = false -python-versions = ">=3.7" -groups = ["docs"] -files = [ - {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, - {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, -] - [[package]] name = "snowballstemmer" version = "3.0.1" @@ -2410,14 +2501,14 @@ test = ["pytest"] [[package]] name = "starlette" -version = "0.47.3" +version = "1.0.0" description = "The little ASGI library that shines." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["docs"] files = [ - {file = "starlette-0.47.3-py3-none-any.whl", hash = "sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51"}, - {file = "starlette-0.47.3.tar.gz", hash = "sha256:6bc94f839cc176c4858894f1f8908f0ab79dfec1a6b8402f6da9be26ebea52e9"}, + {file = "starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b"}, + {file = "starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149"}, ] [package.dependencies] @@ -2429,83 +2520,114 @@ full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart [[package]] name = "tomli" -version = "2.2.1" +version = "2.4.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" groups = ["dev", "docs", "test"] markers = "python_version == \"3.10\"" files = [ - {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, - {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, - {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, - {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, - {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, - {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, - {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, - {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, - {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, - {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, + {file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"}, + {file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"}, + {file = "tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076"}, + {file = "tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9"}, + {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c"}, + {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc"}, + {file = "tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049"}, + {file = "tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e"}, + {file = "tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece"}, + {file = "tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a"}, + {file = "tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085"}, + {file = "tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9"}, + {file = "tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5"}, + {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585"}, + {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1"}, + {file = "tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917"}, + {file = "tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9"}, + {file = "tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257"}, + {file = "tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54"}, + {file = "tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a"}, + {file = "tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897"}, + {file = "tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f"}, + {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d"}, + {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5"}, + {file = "tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd"}, + {file = "tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36"}, + {file = "tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd"}, + {file = "tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf"}, + {file = "tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac"}, + {file = "tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662"}, + {file = "tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853"}, + {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15"}, + {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba"}, + {file = "tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6"}, + {file = "tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7"}, + {file = "tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232"}, + {file = "tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4"}, + {file = "tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c"}, + {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d"}, + {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41"}, + {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c"}, + {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f"}, + {file = "tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8"}, + {file = "tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26"}, + {file = "tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396"}, + {file = "tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe"}, + {file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"}, ] [[package]] -name = "tomlkit" -version = "0.13.3" -description = "Style preserving TOML library" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0"}, - {file = "tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1"}, -] - -[[package]] -name = "tox" -version = "4.28.4" -description = "tox is a generic virtualenv management and test command line tool" +name = "tomli-w" +version = "1.2.0" +description = "A lil' TOML writer" optional = false python-versions = ">=3.9" groups = ["test"] files = [ - {file = "tox-4.28.4-py3-none-any.whl", hash = "sha256:8d4ad9ee916ebbb59272bb045e154a10fa12e3bbdcf94cc5185cbdaf9b241f99"}, - {file = "tox-4.28.4.tar.gz", hash = "sha256:b5b14c6307bd8994ff1eba5074275826620325ee1a4f61316959d562bfd70b9d"}, + {file = "tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90"}, + {file = "tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021"}, +] + +[[package]] +name = "tomlkit" +version = "0.14.0" +description = "Style preserving TOML library" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680"}, + {file = "tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064"}, +] + +[[package]] +name = "tox" +version = "4.52.0" +description = "tox is a generic virtualenv management and test command line tool" +optional = false +python-versions = ">=3.10" +groups = ["test"] +files = [ + {file = "tox-4.52.0-py3-none-any.whl", hash = "sha256:624d8ea4a8c6d5e8d168eedf0e318d736fb22e83ca83137d001ac65ffdec46fd"}, + {file = "tox-4.52.0.tar.gz", hash = "sha256:6054abf5c8b61d58776fbec991f9bf0d34bb883862beb93d2fe55601ef3977c9"}, ] [package.dependencies] -cachetools = ">=6.1" -chardet = ">=5.2" +cachetools = ">=7.0.3" colorama = ">=0.4.6" -filelock = ">=3.18" -packaging = ">=25" -platformdirs = ">=4.3.8" +filelock = ">=3.25" +packaging = ">=26" +platformdirs = ">=4.9.4" pluggy = ">=1.6" -pyproject-api = ">=1.9.1" -tomli = {version = ">=2.2.1", markers = "python_version < \"3.11\""} -typing-extensions = {version = ">=4.14.1", markers = "python_version < \"3.11\""} -virtualenv = ">=20.31.2" +pyproject-api = ">=1.10" +python-discovery = ">=1.2.1" +tomli = {version = ">=2.4", markers = "python_version < \"3.11\""} +tomli-w = ">=1.2" +typing-extensions = {version = ">=4.15", markers = "python_version < \"3.11\""} +virtualenv = ">=21.1" + +[package.extras] +completion = ["argcomplete (>=3.6.3)"] [[package]] name = "typing-extensions" @@ -2522,44 +2644,44 @@ markers = {main = "python_version <= \"3.12\"", dev = "python_version == \"3.10\ [[package]] name = "tzdata" -version = "2025.2" +version = "2025.3" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" groups = ["main"] files = [ - {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, - {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, + {file = "tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1"}, + {file = "tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7"}, ] [[package]] name = "urllib3" -version = "2.5.0" +version = "2.6.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" groups = ["docs"] files = [ - {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, - {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, + {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, + {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, ] [package.extras] -brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "uvicorn" -version = "0.35.0" +version = "0.42.0" description = "The lightning-fast ASGI server." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["docs"] files = [ - {file = "uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a"}, - {file = "uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01"}, + {file = "uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359"}, + {file = "uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775"}, ] [package.dependencies] @@ -2568,144 +2690,144 @@ h11 = ">=0.8" typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} [package.extras] -standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] +standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.20)", "websockets (>=10.4)"] [[package]] name = "virtualenv" -version = "20.34.0" +version = "21.2.0" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.8" groups = ["test"] files = [ - {file = "virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026"}, - {file = "virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a"}, + {file = "virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f"}, + {file = "virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098"}, ] [package.dependencies] distlib = ">=0.3.7,<1" -filelock = ">=3.12.2,<4" +filelock = {version = ">=3.24.2,<4", markers = "python_version >= \"3.10\""} platformdirs = ">=3.9.1,<5" +python-discovery = ">=1" typing-extensions = {version = ">=4.13.2", markers = "python_version < \"3.11\""} -[package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] - [[package]] name = "watchfiles" -version = "1.1.0" +version = "1.1.1" description = "Simple, modern and high performance file watching and code reload in python." optional = false python-versions = ">=3.9" groups = ["docs"] files = [ - {file = "watchfiles-1.1.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:27f30e14aa1c1e91cb653f03a63445739919aef84c8d2517997a83155e7a2fcc"}, - {file = "watchfiles-1.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3366f56c272232860ab45c77c3ca7b74ee819c8e1f6f35a7125556b198bbc6df"}, - {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8412eacef34cae2836d891836a7fff7b754d6bcac61f6c12ba5ca9bc7e427b68"}, - {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:df670918eb7dd719642e05979fc84704af913d563fd17ed636f7c4783003fdcc"}, - {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d7642b9bc4827b5518ebdb3b82698ada8c14c7661ddec5fe719f3e56ccd13c97"}, - {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:199207b2d3eeaeb80ef4411875a6243d9ad8bc35b07fc42daa6b801cc39cc41c"}, - {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a479466da6db5c1e8754caee6c262cd373e6e6c363172d74394f4bff3d84d7b5"}, - {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:935f9edd022ec13e447e5723a7d14456c8af254544cefbc533f6dd276c9aa0d9"}, - {file = "watchfiles-1.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8076a5769d6bdf5f673a19d51da05fc79e2bbf25e9fe755c47595785c06a8c72"}, - {file = "watchfiles-1.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:86b1e28d4c37e89220e924305cd9f82866bb0ace666943a6e4196c5df4d58dcc"}, - {file = "watchfiles-1.1.0-cp310-cp310-win32.whl", hash = "sha256:d1caf40c1c657b27858f9774d5c0e232089bca9cb8ee17ce7478c6e9264d2587"}, - {file = "watchfiles-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:a89c75a5b9bc329131115a409d0acc16e8da8dfd5867ba59f1dd66ae7ea8fa82"}, - {file = "watchfiles-1.1.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c9649dfc57cc1f9835551deb17689e8d44666315f2e82d337b9f07bd76ae3aa2"}, - {file = "watchfiles-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:406520216186b99374cdb58bc48e34bb74535adec160c8459894884c983a149c"}, - {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb45350fd1dc75cd68d3d72c47f5b513cb0578da716df5fba02fff31c69d5f2d"}, - {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:11ee4444250fcbeb47459a877e5e80ed994ce8e8d20283857fc128be1715dac7"}, - {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bda8136e6a80bdea23e5e74e09df0362744d24ffb8cd59c4a95a6ce3d142f79c"}, - {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b915daeb2d8c1f5cee4b970f2e2c988ce6514aace3c9296e58dd64dc9aa5d575"}, - {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed8fc66786de8d0376f9f913c09e963c66e90ced9aa11997f93bdb30f7c872a8"}, - {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe4371595edf78c41ef8ac8df20df3943e13defd0efcb732b2e393b5a8a7a71f"}, - {file = "watchfiles-1.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b7c5f6fe273291f4d414d55b2c80d33c457b8a42677ad14b4b47ff025d0893e4"}, - {file = "watchfiles-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7738027989881e70e3723c75921f1efa45225084228788fc59ea8c6d732eb30d"}, - {file = "watchfiles-1.1.0-cp311-cp311-win32.whl", hash = "sha256:622d6b2c06be19f6e89b1d951485a232e3b59618def88dbeda575ed8f0d8dbf2"}, - {file = "watchfiles-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:48aa25e5992b61debc908a61ab4d3f216b64f44fdaa71eb082d8b2de846b7d12"}, - {file = "watchfiles-1.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:00645eb79a3faa70d9cb15c8d4187bb72970b2470e938670240c7998dad9f13a"}, - {file = "watchfiles-1.1.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9dc001c3e10de4725c749d4c2f2bdc6ae24de5a88a339c4bce32300a31ede179"}, - {file = "watchfiles-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9ba68ec283153dead62cbe81872d28e053745f12335d037de9cbd14bd1877f5"}, - {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:130fc497b8ee68dce163e4254d9b0356411d1490e868bd8790028bc46c5cc297"}, - {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:50a51a90610d0845a5931a780d8e51d7bd7f309ebc25132ba975aca016b576a0"}, - {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc44678a72ac0910bac46fa6a0de6af9ba1355669b3dfaf1ce5f05ca7a74364e"}, - {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a543492513a93b001975ae283a51f4b67973662a375a403ae82f420d2c7205ee"}, - {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ac164e20d17cc285f2b94dc31c384bc3aa3dd5e7490473b3db043dd70fbccfd"}, - {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7590d5a455321e53857892ab8879dce62d1f4b04748769f5adf2e707afb9d4f"}, - {file = "watchfiles-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:37d3d3f7defb13f62ece99e9be912afe9dd8a0077b7c45ee5a57c74811d581a4"}, - {file = "watchfiles-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7080c4bb3efd70a07b1cc2df99a7aa51d98685be56be6038c3169199d0a1c69f"}, - {file = "watchfiles-1.1.0-cp312-cp312-win32.whl", hash = "sha256:cbcf8630ef4afb05dc30107bfa17f16c0896bb30ee48fc24bf64c1f970f3b1fd"}, - {file = "watchfiles-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:cbd949bdd87567b0ad183d7676feb98136cde5bb9025403794a4c0db28ed3a47"}, - {file = "watchfiles-1.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:0a7d40b77f07be87c6faa93d0951a0fcd8cbca1ddff60a1b65d741bac6f3a9f6"}, - {file = "watchfiles-1.1.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5007f860c7f1f8df471e4e04aaa8c43673429047d63205d1630880f7637bca30"}, - {file = "watchfiles-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:20ecc8abbd957046f1fe9562757903f5eaf57c3bce70929fda6c7711bb58074a"}, - {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2f0498b7d2a3c072766dba3274fe22a183dbea1f99d188f1c6c72209a1063dc"}, - {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:239736577e848678e13b201bba14e89718f5c2133dfd6b1f7846fa1b58a8532b"}, - {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eff4b8d89f444f7e49136dc695599a591ff769300734446c0a86cba2eb2f9895"}, - {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12b0a02a91762c08f7264e2e79542f76870c3040bbc847fb67410ab81474932a"}, - {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29e7bc2eee15cbb339c68445959108803dc14ee0c7b4eea556400131a8de462b"}, - {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9481174d3ed982e269c090f780122fb59cee6c3796f74efe74e70f7780ed94c"}, - {file = "watchfiles-1.1.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:80f811146831c8c86ab17b640801c25dc0a88c630e855e2bef3568f30434d52b"}, - {file = "watchfiles-1.1.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:60022527e71d1d1fda67a33150ee42869042bce3d0fcc9cc49be009a9cded3fb"}, - {file = "watchfiles-1.1.0-cp313-cp313-win32.whl", hash = "sha256:32d6d4e583593cb8576e129879ea0991660b935177c0f93c6681359b3654bfa9"}, - {file = "watchfiles-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:f21af781a4a6fbad54f03c598ab620e3a77032c5878f3d780448421a6e1818c7"}, - {file = "watchfiles-1.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:5366164391873ed76bfdf618818c82084c9db7fac82b64a20c44d335eec9ced5"}, - {file = "watchfiles-1.1.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:17ab167cca6339c2b830b744eaf10803d2a5b6683be4d79d8475d88b4a8a4be1"}, - {file = "watchfiles-1.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:328dbc9bff7205c215a7807da7c18dce37da7da718e798356212d22696404339"}, - {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7208ab6e009c627b7557ce55c465c98967e8caa8b11833531fdf95799372633"}, - {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8f6f72974a19efead54195bc9bed4d850fc047bb7aa971268fd9a8387c89011"}, - {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d181ef50923c29cf0450c3cd47e2f0557b62218c50b2ab8ce2ecaa02bd97e670"}, - {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adb4167043d3a78280d5d05ce0ba22055c266cf8655ce942f2fb881262ff3cdf"}, - {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5701dc474b041e2934a26d31d39f90fac8a3dee2322b39f7729867f932b1d4"}, - {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b067915e3c3936966a8607f6fe5487df0c9c4afb85226613b520890049deea20"}, - {file = "watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:9c733cda03b6d636b4219625a4acb5c6ffb10803338e437fb614fef9516825ef"}, - {file = "watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:cc08ef8b90d78bfac66f0def80240b0197008e4852c9f285907377b2947ffdcb"}, - {file = "watchfiles-1.1.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9974d2f7dc561cce3bb88dfa8eb309dab64c729de85fba32e98d75cf24b66297"}, - {file = "watchfiles-1.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c68e9f1fcb4d43798ad8814c4c1b61547b014b667216cb754e606bfade587018"}, - {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95ab1594377effac17110e1352989bdd7bdfca9ff0e5eeccd8c69c5389b826d0"}, - {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fba9b62da882c1be1280a7584ec4515d0a6006a94d6e5819730ec2eab60ffe12"}, - {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3434e401f3ce0ed6b42569128b3d1e3af773d7ec18751b918b89cd49c14eaafb"}, - {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa257a4d0d21fcbca5b5fcba9dca5a78011cb93c0323fb8855c6d2dfbc76eb77"}, - {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fd1b3879a578a8ec2076c7961076df540b9af317123f84569f5a9ddee64ce92"}, - {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62cc7a30eeb0e20ecc5f4bd113cd69dcdb745a07c68c0370cea919f373f65d9e"}, - {file = "watchfiles-1.1.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:891c69e027748b4a73847335d208e374ce54ca3c335907d381fde4e41661b13b"}, - {file = "watchfiles-1.1.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:12fe8eaffaf0faa7906895b4f8bb88264035b3f0243275e0bf24af0436b27259"}, - {file = "watchfiles-1.1.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bfe3c517c283e484843cb2e357dd57ba009cff351edf45fb455b5fbd1f45b15f"}, - {file = "watchfiles-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9ccbf1f129480ed3044f540c0fdbc4ee556f7175e5ab40fe077ff6baf286d4e"}, - {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba0e3255b0396cac3cc7bbace76404dd72b5438bf0d8e7cefa2f79a7f3649caa"}, - {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4281cd9fce9fc0a9dbf0fc1217f39bf9cf2b4d315d9626ef1d4e87b84699e7e8"}, - {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d2404af8db1329f9a3c9b79ff63e0ae7131986446901582067d9304ae8aaf7f"}, - {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e78b6ed8165996013165eeabd875c5dfc19d41b54f94b40e9fff0eb3193e5e8e"}, - {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:249590eb75ccc117f488e2fabd1bfa33c580e24b96f00658ad88e38844a040bb"}, - {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d05686b5487cfa2e2c28ff1aa370ea3e6c5accfe6435944ddea1e10d93872147"}, - {file = "watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d0e10e6f8f6dc5762adee7dece33b722282e1f59aa6a55da5d493a97282fedd8"}, - {file = "watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:af06c863f152005c7592df1d6a7009c836a247c9d8adb78fef8575a5a98699db"}, - {file = "watchfiles-1.1.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:865c8e95713744cf5ae261f3067861e9da5f1370ba91fc536431e29b418676fa"}, - {file = "watchfiles-1.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:42f92befc848bb7a19658f21f3e7bae80d7d005d13891c62c2cd4d4d0abb3433"}, - {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa0cc8365ab29487eb4f9979fd41b22549853389e22d5de3f134a6796e1b05a4"}, - {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:90ebb429e933645f3da534c89b29b665e285048973b4d2b6946526888c3eb2c7"}, - {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c588c45da9b08ab3da81d08d7987dae6d2a3badd63acdb3e206a42dbfa7cb76f"}, - {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c55b0f9f68590115c25272b06e63f0824f03d4fc7d6deed43d8ad5660cabdbf"}, - {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd17a1e489f02ce9117b0de3c0b1fab1c3e2eedc82311b299ee6b6faf6c23a29"}, - {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da71945c9ace018d8634822f16cbc2a78323ef6c876b1d34bbf5d5222fd6a72e"}, - {file = "watchfiles-1.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:51556d5004887045dba3acdd1fdf61dddea2be0a7e18048b5e853dcd37149b86"}, - {file = "watchfiles-1.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04e4ed5d1cd3eae68c89bcc1a485a109f39f2fd8de05f705e98af6b5f1861f1f"}, - {file = "watchfiles-1.1.0-cp39-cp39-win32.whl", hash = "sha256:c600e85f2ffd9f1035222b1a312aff85fd11ea39baff1d705b9b047aad2ce267"}, - {file = "watchfiles-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:3aba215958d88182e8d2acba0fdaf687745180974946609119953c0e112397dc"}, - {file = "watchfiles-1.1.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3a6fd40bbb50d24976eb275ccb55cd1951dfb63dbc27cae3066a6ca5f4beabd5"}, - {file = "watchfiles-1.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9f811079d2f9795b5d48b55a37aa7773680a5659afe34b54cc1d86590a51507d"}, - {file = "watchfiles-1.1.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a2726d7bfd9f76158c84c10a409b77a320426540df8c35be172444394b17f7ea"}, - {file = "watchfiles-1.1.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df32d59cb9780f66d165a9a7a26f19df2c7d24e3bd58713108b41d0ff4f929c6"}, - {file = "watchfiles-1.1.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0ece16b563b17ab26eaa2d52230c9a7ae46cf01759621f4fbbca280e438267b3"}, - {file = "watchfiles-1.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:51b81e55d40c4b4aa8658427a3ee7ea847c591ae9e8b81ef94a90b668999353c"}, - {file = "watchfiles-1.1.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2bcdc54ea267fe72bfc7d83c041e4eb58d7d8dc6f578dfddb52f037ce62f432"}, - {file = "watchfiles-1.1.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:923fec6e5461c42bd7e3fd5ec37492c6f3468be0499bc0707b4bbbc16ac21792"}, - {file = "watchfiles-1.1.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7b3443f4ec3ba5aa00b0e9fa90cf31d98321cbff8b925a7c7b84161619870bc9"}, - {file = "watchfiles-1.1.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7049e52167fc75fc3cc418fc13d39a8e520cbb60ca08b47f6cedb85e181d2f2a"}, - {file = "watchfiles-1.1.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54062ef956807ba806559b3c3d52105ae1827a0d4ab47b621b31132b6b7e2866"}, - {file = "watchfiles-1.1.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a7bd57a1bb02f9d5c398c0c1675384e7ab1dd39da0ca50b7f09af45fa435277"}, - {file = "watchfiles-1.1.0.tar.gz", hash = "sha256:693ed7ec72cbfcee399e92c895362b6e66d63dac6b91e2c11ae03d10d503e575"}, + {file = "watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c"}, + {file = "watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43"}, + {file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31"}, + {file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac"}, + {file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d"}, + {file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d"}, + {file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863"}, + {file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab"}, + {file = "watchfiles-1.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82"}, + {file = "watchfiles-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4"}, + {file = "watchfiles-1.1.1-cp310-cp310-win32.whl", hash = "sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844"}, + {file = "watchfiles-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e"}, + {file = "watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5"}, + {file = "watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741"}, + {file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6"}, + {file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b"}, + {file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14"}, + {file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d"}, + {file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff"}, + {file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606"}, + {file = "watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701"}, + {file = "watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10"}, + {file = "watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849"}, + {file = "watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4"}, + {file = "watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e"}, + {file = "watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d"}, + {file = "watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610"}, + {file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af"}, + {file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6"}, + {file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce"}, + {file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa"}, + {file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb"}, + {file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803"}, + {file = "watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94"}, + {file = "watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43"}, + {file = "watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9"}, + {file = "watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9"}, + {file = "watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404"}, + {file = "watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18"}, + {file = "watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a"}, + {file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219"}, + {file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428"}, + {file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0"}, + {file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150"}, + {file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae"}, + {file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d"}, + {file = "watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b"}, + {file = "watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374"}, + {file = "watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0"}, + {file = "watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42"}, + {file = "watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18"}, + {file = "watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da"}, + {file = "watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051"}, + {file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e"}, + {file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70"}, + {file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261"}, + {file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620"}, + {file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04"}, + {file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77"}, + {file = "watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef"}, + {file = "watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf"}, + {file = "watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5"}, + {file = "watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd"}, + {file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb"}, + {file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5"}, + {file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3"}, + {file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33"}, + {file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510"}, + {file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05"}, + {file = "watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6"}, + {file = "watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81"}, + {file = "watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b"}, + {file = "watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a"}, + {file = "watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02"}, + {file = "watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21"}, + {file = "watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5"}, + {file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7"}, + {file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101"}, + {file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44"}, + {file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c"}, + {file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc"}, + {file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c"}, + {file = "watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099"}, + {file = "watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01"}, + {file = "watchfiles-1.1.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c882d69f6903ef6092bedfb7be973d9319940d56b8427ab9187d1ecd73438a70"}, + {file = "watchfiles-1.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d6ff426a7cb54f310d51bfe83fe9f2bbe40d540c741dc974ebc30e6aa238f52e"}, + {file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79ff6c6eadf2e3fc0d7786331362e6ef1e51125892c75f1004bd6b52155fb956"}, + {file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c1f5210f1b8fc91ead1283c6fd89f70e76fb07283ec738056cf34d51e9c1d62c"}, + {file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9c4702f29ca48e023ffd9b7ff6b822acdf47cb1ff44cb490a3f1d5ec8987e9c"}, + {file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acb08650863767cbc58bca4813b92df4d6c648459dcaa3d4155681962b2aa2d3"}, + {file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08af70fd77eee58549cd69c25055dc344f918d992ff626068242259f98d598a2"}, + {file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c3631058c37e4a0ec440bf583bc53cdbd13e5661bb6f465bc1d88ee9a0a4d02"}, + {file = "watchfiles-1.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:cf57a27fb986c6243d2ee78392c503826056ffe0287e8794503b10fb51b881be"}, + {file = "watchfiles-1.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d7e7067c98040d646982daa1f37a33d3544138ea155536c2e0e63e07ff8a7e0f"}, + {file = "watchfiles-1.1.1-cp39-cp39-win32.whl", hash = "sha256:6c9c9262f454d1c4d8aaa7050121eb4f3aea197360553699520767daebf2180b"}, + {file = "watchfiles-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:74472234c8370669850e1c312490f6026d132ca2d396abfad8830b4f1c096957"}, + {file = "watchfiles-1.1.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3"}, + {file = "watchfiles-1.1.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2"}, + {file = "watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d"}, + {file = "watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b"}, + {file = "watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88"}, + {file = "watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336"}, + {file = "watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24"}, + {file = "watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49"}, + {file = "watchfiles-1.1.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cdab464fee731e0884c35ae3588514a9bcf718d0e2c82169c1c4a85cc19c3c7f"}, + {file = "watchfiles-1.1.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:3dbd8cbadd46984f802f6d479b7e3afa86c42d13e8f0f322d669d79722c8ec34"}, + {file = "watchfiles-1.1.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5524298e3827105b61951a29c3512deb9578586abf3a7c5da4a8069df247cccc"}, + {file = "watchfiles-1.1.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b943d3668d61cfa528eb949577479d3b077fd25fb83c641235437bc0b5bc60e"}, + {file = "watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2"}, ] [package.dependencies] @@ -2713,81 +2835,73 @@ anyio = ">=3.0.0" [[package]] name = "websockets" -version = "15.0.1" +version = "16.0" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["docs"] files = [ - {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b"}, - {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205"}, - {file = "websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a"}, - {file = "websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e"}, - {file = "websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf"}, - {file = "websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb"}, - {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d"}, - {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9"}, - {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c"}, - {file = "websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256"}, - {file = "websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41"}, - {file = "websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431"}, - {file = "websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57"}, - {file = "websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905"}, - {file = "websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562"}, - {file = "websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792"}, - {file = "websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413"}, - {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8"}, - {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3"}, - {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf"}, - {file = "websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85"}, - {file = "websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065"}, - {file = "websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3"}, - {file = "websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665"}, - {file = "websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2"}, - {file = "websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215"}, - {file = "websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5"}, - {file = "websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65"}, - {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe"}, - {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4"}, - {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597"}, - {file = "websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9"}, - {file = "websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7"}, - {file = "websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931"}, - {file = "websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675"}, - {file = "websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151"}, - {file = "websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22"}, - {file = "websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f"}, - {file = "websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8"}, - {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375"}, - {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d"}, - {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4"}, - {file = "websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa"}, - {file = "websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561"}, - {file = "websockets-15.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5f4c04ead5aed67c8a1a20491d54cdfba5884507a48dd798ecaf13c74c4489f5"}, - {file = "websockets-15.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abdc0c6c8c648b4805c5eacd131910d2a7f6455dfd3becab248ef108e89ab16a"}, - {file = "websockets-15.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a625e06551975f4b7ea7102bc43895b90742746797e2e14b70ed61c43a90f09b"}, - {file = "websockets-15.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d591f8de75824cbb7acad4e05d2d710484f15f29d4a915092675ad3456f11770"}, - {file = "websockets-15.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47819cea040f31d670cc8d324bb6435c6f133b8c7a19ec3d61634e62f8d8f9eb"}, - {file = "websockets-15.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac017dd64572e5c3bd01939121e4d16cf30e5d7e110a119399cf3133b63ad054"}, - {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4a9fac8e469d04ce6c25bb2610dc535235bd4aa14996b4e6dbebf5e007eba5ee"}, - {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363c6f671b761efcb30608d24925a382497c12c506b51661883c3e22337265ed"}, - {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2034693ad3097d5355bfdacfffcbd3ef5694f9718ab7f29c29689a9eae841880"}, - {file = "websockets-15.0.1-cp39-cp39-win32.whl", hash = "sha256:3b1ac0d3e594bf121308112697cf4b32be538fb1444468fb0a6ae4feebc83411"}, - {file = "websockets-15.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:b7643a03db5c95c799b89b31c036d5f27eeb4d259c798e878d6937d71832b1e4"}, - {file = "websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3"}, - {file = "websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1"}, - {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475"}, - {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9"}, - {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04"}, - {file = "websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122"}, - {file = "websockets-15.0.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7f493881579c90fc262d9cdbaa05a6b54b3811c2f300766748db79f098db9940"}, - {file = "websockets-15.0.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:47b099e1f4fbc95b701b6e85768e1fcdaf1630f3cbe4765fa216596f12310e2e"}, - {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67f2b6de947f8c757db2db9c71527933ad0019737ec374a8a6be9a956786aaf9"}, - {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d08eb4c2b7d6c41da6ca0600c077e93f5adcfd979cd777d747e9ee624556da4b"}, - {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b826973a4a2ae47ba357e4e82fa44a463b8f168e1ca775ac64521442b19e87f"}, - {file = "websockets-15.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:21c1fa28a6a7e3cbdc171c694398b6df4744613ce9b36b1a498e816787e28123"}, - {file = "websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f"}, - {file = "websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee"}, + {file = "websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a"}, + {file = "websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0"}, + {file = "websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957"}, + {file = "websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72"}, + {file = "websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde"}, + {file = "websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3"}, + {file = "websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3"}, + {file = "websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9"}, + {file = "websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35"}, + {file = "websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8"}, + {file = "websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad"}, + {file = "websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d"}, + {file = "websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe"}, + {file = "websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b"}, + {file = "websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5"}, + {file = "websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64"}, + {file = "websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6"}, + {file = "websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac"}, + {file = "websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00"}, + {file = "websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79"}, + {file = "websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39"}, + {file = "websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c"}, + {file = "websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f"}, + {file = "websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1"}, + {file = "websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2"}, + {file = "websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89"}, + {file = "websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea"}, + {file = "websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9"}, + {file = "websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230"}, + {file = "websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c"}, + {file = "websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5"}, + {file = "websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82"}, + {file = "websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8"}, + {file = "websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f"}, + {file = "websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a"}, + {file = "websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156"}, + {file = "websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0"}, + {file = "websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904"}, + {file = "websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4"}, + {file = "websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e"}, + {file = "websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4"}, + {file = "websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1"}, + {file = "websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3"}, + {file = "websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8"}, + {file = "websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d"}, + {file = "websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244"}, + {file = "websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e"}, + {file = "websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641"}, + {file = "websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8"}, + {file = "websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e"}, + {file = "websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944"}, + {file = "websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206"}, + {file = "websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6"}, + {file = "websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd"}, + {file = "websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d"}, + {file = "websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03"}, + {file = "websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da"}, + {file = "websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c"}, + {file = "websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767"}, + {file = "websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec"}, + {file = "websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5"}, ] [metadata] From cfa1da9f4da2bc6ac0dfcdfafcd59abd64e6e858 Mon Sep 17 00:00:00 2001 From: madrigal Date: Thu, 2 Apr 2026 10:37:42 -0400 Subject: [PATCH 13/43] Linting --- src/ria_toolkit_oss/annotations/__init__.py | 1 - .../annotations/threshold_qualifier.py | 5 +++- .../datatypes/datasets/radio_dataset.py | 6 +++-- src/ria_toolkit_oss/io/recording.py | 2 +- src/ria_toolkit_oss/sdr/blade.py | 12 ++++++---- src/ria_toolkit_oss/sdr/hackrf.py | 6 +++-- src/ria_toolkit_oss/sdr/pluto.py | 24 ++++++++++++------- src/ria_toolkit_oss/sdr/rtlsdr.py | 12 ++++++---- src/ria_toolkit_oss/sdr/thinkrf.py | 6 +++-- src/ria_toolkit_oss/sdr/usrp.py | 12 ++++++---- .../signal/block_generator/basic/add.py | 6 +++-- .../frequency_translation/upconversion.py | 1 + .../signal/block_generator/process_block.py | 6 +++-- .../block_generator/recordable_block.py | 6 +++-- .../source/recording_source.py | 6 +++-- .../transforms/iq_augmentations.py | 6 +++-- src/ria_toolkit_oss/view/view_signal.py | 15 +++++------- .../view/view_signal_simple.py | 7 +++++- .../ria_toolkit_oss/annotate.py | 12 ++++++++-- .../ria_toolkit_oss/commands.py | 2 +- .../ria_toolkit_oss/view.py | 3 +-- 21 files changed, 102 insertions(+), 54 deletions(-) diff --git a/src/ria_toolkit_oss/annotations/__init__.py b/src/ria_toolkit_oss/annotations/__init__.py index ebb3d10..1542dcf 100644 --- a/src/ria_toolkit_oss/annotations/__init__.py +++ b/src/ria_toolkit_oss/annotations/__init__.py @@ -1,4 +1,3 @@ - """ The annotations package contains tools and utilities for creating, managing, and processing annotations. diff --git a/src/ria_toolkit_oss/annotations/threshold_qualifier.py b/src/ria_toolkit_oss/annotations/threshold_qualifier.py index 338f13c..1d23ef6 100644 --- a/src/ria_toolkit_oss/annotations/threshold_qualifier.py +++ b/src/ria_toolkit_oss/annotations/threshold_qualifier.py @@ -167,7 +167,10 @@ def _estimate_spectral_bounds(signal_segment: np.ndarray, sample_rate: float) -> else: runs = _find_ranges(sig_indices, max_gap=max(1, spectral_smooth_bins // 2)) peak_idx = int(np.argmax(smoothed_fft)) - lo_idx, hi_idx = min(runs, key=lambda run: 0 if run[0] <= peak_idx <= run[1] else min(abs(run[0] - peak_idx), abs(run[1] - peak_idx))) + lo_idx, hi_idx = min( + runs, + key=lambda run: 0 if run[0] <= peak_idx <= run[1] else min(abs(run[0] - peak_idx), abs(run[1] - peak_idx)), + ) # Prevent extremely narrow tone boxes from collapsing to just a few bins. min_total_bw_hz = 20_000.0 diff --git a/src/ria_toolkit_oss/datatypes/datasets/radio_dataset.py b/src/ria_toolkit_oss/datatypes/datasets/radio_dataset.py index cf3fd23..7a70589 100644 --- a/src/ria_toolkit_oss/datatypes/datasets/radio_dataset.py +++ b/src/ria_toolkit_oss/datatypes/datasets/radio_dataset.py @@ -956,8 +956,10 @@ def get_result_sizes( # noqa: C901 # TODO: Simplify function # Check that each class that will be augmented does not already suffice target_size for cls_name, target_size_value in zip(classes_to_augment, target_size): if class_sizes[cls_name] >= target_size_value: - raise ValueError(f"""target_size of {target_size_value} is already sufficed for current size of - {class_sizes[cls_name]} for class: {cls_name}""") + raise ValueError( + f"""target_size of {target_size_value} is already sufficed for current size of + {class_sizes[cls_name]} for class: {cls_name}""" + ) for index, class_name in enumerate(classes_to_augment): result_sizes[class_name] = target_size[index] diff --git a/src/ria_toolkit_oss/io/recording.py b/src/ria_toolkit_oss/io/recording.py index 0234e11..f56012f 100644 --- a/src/ria_toolkit_oss/io/recording.py +++ b/src/ria_toolkit_oss/io/recording.py @@ -136,9 +136,9 @@ def from_npy(file: os.PathLike | str, legacy: bool = False) -> Recording: annotations = [] except ModuleNotFoundError: # File was pickled with utils.data.Annotation — remap to ria_toolkit_oss - import pickle import sys import types + import ria_toolkit_oss.datatypes.annotation as _ann_mod utils_shim = types.ModuleType("utils") diff --git a/src/ria_toolkit_oss/sdr/blade.py b/src/ria_toolkit_oss/sdr/blade.py index 576a91c..c8e6f3f 100644 --- a/src/ria_toolkit_oss/sdr/blade.py +++ b/src/ria_toolkit_oss/sdr/blade.py @@ -474,8 +474,10 @@ class Blade(SDR): if gain_mode == "relative": if gain > 0: - raise SDRParameterError("When gain_mode = 'relative', gain must be < 0. This sets \ - the gain relative to the maximum possible gain.") + raise SDRParameterError( + "When gain_mode = 'relative', gain must be < 0. This sets \ + the gain relative to the maximum possible gain." + ) else: abs_gain = rx_gain_max + gain else: @@ -548,8 +550,10 @@ class Blade(SDR): if gain_mode == "relative": if gain > 0: - raise SDRParameterError("When gain_mode = 'relative', gain must be < 0. This sets\ - the gain relative to the maximum possible gain.") + raise SDRParameterError( + "When gain_mode = 'relative', gain must be < 0. This sets\ + the gain relative to the maximum possible gain." + ) else: abs_gain = tx_gain_max + gain else: diff --git a/src/ria_toolkit_oss/sdr/hackrf.py b/src/ria_toolkit_oss/sdr/hackrf.py index 79e00a7..06b49a7 100644 --- a/src/ria_toolkit_oss/sdr/hackrf.py +++ b/src/ria_toolkit_oss/sdr/hackrf.py @@ -172,8 +172,10 @@ class HackRF(SDR): tx_gain_max = 47 if gain_mode == "relative": if gain > 0: - raise SDRParameterError("When gain_mode = 'relative', gain must be < 0. This \ - sets the gain relative to the maximum possible gain.") + raise SDRParameterError( + "When gain_mode = 'relative', gain must be < 0. This \ + sets the gain relative to the maximum possible gain." + ) else: abs_gain = tx_gain_max + gain else: diff --git a/src/ria_toolkit_oss/sdr/pluto.py b/src/ria_toolkit_oss/sdr/pluto.py index 701198a..47af8df 100644 --- a/src/ria_toolkit_oss/sdr/pluto.py +++ b/src/ria_toolkit_oss/sdr/pluto.py @@ -274,16 +274,20 @@ class Pluto(SDR): data = [self._convert_tx_samples(samples), self._convert_tx_samples(samples)] else: if len(recording) > 2: - warnings.warn("More recordings were provided than channels in the Pluto. \ - Only the first two recordings will be used") + warnings.warn( + "More recordings were provided than channels in the Pluto. \ + Only the first two recordings will be used" + ) sample0 = self._convert_tx_samples(recording.data[0]) sample1 = self._convert_tx_samples(recording.data[1]) data = [sample0, sample1] elif isinstance(recording, list): if len(recording) > 2: - warnings.warn("More recordings were provided than channels in the Pluto. \ - Only the first two recordings will be used") + warnings.warn( + "More recordings were provided than channels in the Pluto. \ + Only the first two recordings will be used" + ) if isinstance(recording[0], np.ndarray): data = [self._convert_tx_samples(recording[0]), self._convert_tx_samples(recording[1])] @@ -423,8 +427,10 @@ class Pluto(SDR): if gain_mode == "relative": if gain > 0: - raise SDRParameterError("When gain_mode = 'relative', gain must be < 0. This sets \ - the gain relative to the maximum possible gain.") + raise SDRParameterError( + "When gain_mode = 'relative', gain must be < 0. This sets \ + the gain relative to the maximum possible gain." + ) else: abs_gain = rx_gain_max + gain else: @@ -534,8 +540,10 @@ class Pluto(SDR): if gain_mode == "relative": if gain > 0: - raise SDRParameterError("When gain_mode = 'relative', gain must be < 0. This sets\ - the gain relative to the maximum possible gain.") + raise SDRParameterError( + "When gain_mode = 'relative', gain must be < 0. This sets\ + the gain relative to the maximum possible gain." + ) else: abs_gain = tx_gain_max + gain else: diff --git a/src/ria_toolkit_oss/sdr/rtlsdr.py b/src/ria_toolkit_oss/sdr/rtlsdr.py index bae677a..d49ab6e 100644 --- a/src/ria_toolkit_oss/sdr/rtlsdr.py +++ b/src/ria_toolkit_oss/sdr/rtlsdr.py @@ -131,15 +131,19 @@ class RTLSDR(SDR): if gain_mode == "relative": if gain > 0: - raise SDRParameterError("When gain_mode = 'relative', gain must be < 0. This sets\ - the gain relative to the maximum possible gain.") + raise SDRParameterError( + "When gain_mode = 'relative', gain must be < 0. This sets\ + the gain relative to the maximum possible gain." + ) target_gain = max_gain + gain else: target_gain = gain if target_gain < min_gain or target_gain > max_gain: - print(f"Requested gain {target_gain} dB out of range;\ - clamping to valid span {min_gain}-{max_gain} dB.") + print( + f"Requested gain {target_gain} dB out of range;\ + clamping to valid span {min_gain}-{max_gain} dB." + ) target_gain = min(max(target_gain, min_gain), max_gain) target_gain = min(available_gains, key=lambda g: abs(g - target_gain)) diff --git a/src/ria_toolkit_oss/sdr/thinkrf.py b/src/ria_toolkit_oss/sdr/thinkrf.py index adc25ff..7d108ab 100644 --- a/src/ria_toolkit_oss/sdr/thinkrf.py +++ b/src/ria_toolkit_oss/sdr/thinkrf.py @@ -392,8 +392,10 @@ class ThinkRF(SDR): actual_sample_rate = self.BASE_SAMPLE_RATE / decimation if abs(actual_sample_rate - requested_sample_rate) > 1e3: # More than 1 kHz difference - print(f"ThinkRF: Requested {requested_sample_rate/1e6:.2f} MS/s → \ - Using decimation={decimation} ({actual_sample_rate/1e6:.2f} MS/s)") + print( + f"ThinkRF: Requested {requested_sample_rate/1e6:.2f} MS/s → \ + Using decimation={decimation} ({actual_sample_rate/1e6:.2f} MS/s)" + ) return decimation, actual_sample_rate diff --git a/src/ria_toolkit_oss/sdr/usrp.py b/src/ria_toolkit_oss/sdr/usrp.py index 70bbc46..f458378 100644 --- a/src/ria_toolkit_oss/sdr/usrp.py +++ b/src/ria_toolkit_oss/sdr/usrp.py @@ -148,8 +148,10 @@ class USRP(SDR): gain_range = self.usrp.get_rx_gain_range() if gain_mode == "relative": if gain > 0: - raise SDRParameterError("When gain_mode = 'relative', gain must be < 0. This sets\ - the gain relative to the maximum possible gain.") + raise SDRParameterError( + "When gain_mode = 'relative', gain must be < 0. This sets\ + the gain relative to the maximum possible gain." + ) else: # set gain relative to max abs_gain = gain_range.stop() + gain @@ -354,8 +356,10 @@ class USRP(SDR): gain_range = self.usrp.get_tx_gain_range() if gain_mode == "relative": if gain > 0: - raise SDRParameterError("When gain_mode = 'relative', gain must be < 0. This sets\ - the gain relative to the maximum possible gain.") + raise SDRParameterError( + "When gain_mode = 'relative', gain must be < 0. This sets\ + the gain relative to the maximum possible gain." + ) else: # set gain relative to max abs_gain = gain_range.stop() + gain diff --git a/src/ria_toolkit_oss/signal/block_generator/basic/add.py b/src/ria_toolkit_oss/signal/block_generator/basic/add.py index fe59f16..9830f7b 100644 --- a/src/ria_toolkit_oss/signal/block_generator/basic/add.py +++ b/src/ria_toolkit_oss/signal/block_generator/basic/add.py @@ -37,8 +37,10 @@ class Add(RecordableBlock, ProcessBlock): samples = block.get_samples(num_samples) if len(samples) != num_samples: - raise ValueError(f"Block {self.__class__.__name__} requested {num_samples} \ - from block {block.__class__.__name__} but got {len(samples)}.") + raise ValueError( + f"Block {self.__class__.__name__} requested {num_samples} \ + from block {block.__class__.__name__} but got {len(samples)}." + ) return samples diff --git a/src/ria_toolkit_oss/signal/block_generator/frequency_translation/upconversion.py b/src/ria_toolkit_oss/signal/block_generator/frequency_translation/upconversion.py index c0272c3..36b7117 100644 --- a/src/ria_toolkit_oss/signal/block_generator/frequency_translation/upconversion.py +++ b/src/ria_toolkit_oss/signal/block_generator/frequency_translation/upconversion.py @@ -1,4 +1,5 @@ import numpy as np + from ria_toolkit_oss.signal.block_generator.block import Block from ria_toolkit_oss.signal.block_generator.data_types import DataType diff --git a/src/ria_toolkit_oss/signal/block_generator/process_block.py b/src/ria_toolkit_oss/signal/block_generator/process_block.py index 98eccd1..4b618cd 100644 --- a/src/ria_toolkit_oss/signal/block_generator/process_block.py +++ b/src/ria_toolkit_oss/signal/block_generator/process_block.py @@ -23,9 +23,11 @@ class ProcessBlock(Block, ABC): ) elif not all(isinstance(item, Block) for item in input): - raise ValueError(f"Invalid input to block '{self.__class__.__name__}'. \ + raise ValueError( + f"Invalid input to block '{self.__class__.__name__}'. \ Expected a list of Block objects but got \ - {'[' + ',' .join(f'{item.__class__.__name__}({repr(item)})' for item in input) + ']'}") + {'[' + ',' .join(f'{item.__class__.__name__}({repr(item)})' for item in input) + ']'}" + ) elif len(input) != len(self.input_type): raise ValueError( diff --git a/src/ria_toolkit_oss/signal/block_generator/recordable_block.py b/src/ria_toolkit_oss/signal/block_generator/recordable_block.py index 28f2f2b..4b63648 100644 --- a/src/ria_toolkit_oss/signal/block_generator/recordable_block.py +++ b/src/ria_toolkit_oss/signal/block_generator/recordable_block.py @@ -20,8 +20,10 @@ class RecordableBlock(Block, Recordable): :raises ValueError: If the number of samples is incorrect.""" samples = self.get_samples(num_samples) if len(samples) != num_samples: - raise ValueError(f"Error in block {self.__class__.__name__} record(). \ - Requested {num_samples} samples but got {len(samples)}") + raise ValueError( + f"Error in block {self.__class__.__name__} record(). \ + Requested {num_samples} samples but got {len(samples)}" + ) metadata = self._get_metadata() return Recording(data=samples, metadata=metadata) diff --git a/src/ria_toolkit_oss/signal/block_generator/source/recording_source.py b/src/ria_toolkit_oss/signal/block_generator/source/recording_source.py index 8a1e642..1b7795a 100644 --- a/src/ria_toolkit_oss/signal/block_generator/source/recording_source.py +++ b/src/ria_toolkit_oss/signal/block_generator/source/recording_source.py @@ -39,7 +39,9 @@ class RecordingSource(SourceBlock, RecordableBlock): :raises ValueError: If num_samples is greater than the recording length. """ if num_samples - 1 >= self.recording.data.shape[1]: - raise ValueError(f"{num_samples} samples requested from recording source with \ - {self.recording.data.shape[1]} samples available.") + raise ValueError( + f"{num_samples} samples requested from recording source with \ + {self.recording.data.shape[1]} samples available." + ) return self.recording.data[0, 0:num_samples] diff --git a/src/ria_toolkit_oss/transforms/iq_augmentations.py b/src/ria_toolkit_oss/transforms/iq_augmentations.py index 106f151..ca859a0 100644 --- a/src/ria_toolkit_oss/transforms/iq_augmentations.py +++ b/src/ria_toolkit_oss/transforms/iq_augmentations.py @@ -610,8 +610,10 @@ def cut_out( # noqa: C901 # TODO: Simplify function raise ValueError("signal must be CxN complex.") if fill_type not in {"zeros", "ones", "low-snr", "avg-snr", "high-snr"}: - raise UserWarning("""fill_type must be "zeros", "ones", "low-snr", "avg-snr", or "high-snr", - "ones" has been selected by default""") + raise UserWarning( + """fill_type must be "zeros", "ones", "low-snr", "avg-snr", or "high-snr", + "ones" has been selected by default""" + ) if max_section_size < 1 or max_section_size >= n: raise ValueError("max_section_size must be at least 1 and must be less than the length of signal.") diff --git a/src/ria_toolkit_oss/view/view_signal.py b/src/ria_toolkit_oss/view/view_signal.py index 86cfbe7..ded3c8c 100644 --- a/src/ria_toolkit_oss/view/view_signal.py +++ b/src/ria_toolkit_oss/view/view_signal.py @@ -4,7 +4,6 @@ import textwrap from typing import Optional import matplotlib.pyplot as plt -from matplotlib.patches import Patch import numpy as np from matplotlib import gridspec from matplotlib.patches import Patch @@ -14,7 +13,12 @@ from scipy.signal import spectrogram from scipy.signal.windows import hann from ria_toolkit_oss.datatypes.recording import Recording -from ria_toolkit_oss.view.tools import COLORS, decimate, extract_metadata_fields, set_path +from ria_toolkit_oss.view.tools import ( + COLORS, + decimate, + extract_metadata_fields, + set_path, +) def get_fft_size(plot_length): @@ -59,13 +63,6 @@ def view_annotations( annotations = recording.annotations # 2. Setup Color Mapping - available_colors = [ - COLORS.get("magenta", "magenta"), - COLORS.get("accent", "cyan"), - COLORS.get("light", "white"), - "lime", - ] - palette = ["#2196F3", "#9C27B0", "#64B5F6", "#7B1FA2", "#5C6BC0", "#CE93D8", "#1565C0", "#7C4DFF"] unique_labels = sorted(list(set(ann.label for ann in annotations if ann.label))) label_to_color = {label: palette[i % len(palette)] for i, label in enumerate(unique_labels)} diff --git a/src/ria_toolkit_oss/view/view_signal_simple.py b/src/ria_toolkit_oss/view/view_signal_simple.py index d97452e..1b847ab 100644 --- a/src/ria_toolkit_oss/view/view_signal_simple.py +++ b/src/ria_toolkit_oss/view/view_signal_simple.py @@ -13,7 +13,12 @@ from scipy.fft import fft, fftshift from scipy.signal.windows import hann from ria_toolkit_oss.datatypes.recording import Recording -from ria_toolkit_oss.view.tools import COLORS, decimate, extract_metadata_fields, set_path +from ria_toolkit_oss.view.tools import ( + COLORS, + decimate, + extract_metadata_fields, + set_path, +) def _add_annotations(annotations, compact_mode, show_labels, sample_rate_hz, center_freq_hz, ax2): diff --git a/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py b/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py index cdfabb5..4a8d6ac 100644 --- a/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py +++ b/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py @@ -14,7 +14,10 @@ from ria_toolkit_oss.annotations import ( from ria_toolkit_oss.datatypes import Annotation from ria_toolkit_oss.datatypes.recording import Recording from ria_toolkit_oss.io import load_recording, to_blue, to_npy, to_sigmf, to_wav -from ria_toolkit_oss_cli.ria_toolkit_oss.common import format_frequency, format_sample_count +from ria_toolkit_oss_cli.ria_toolkit_oss.common import ( + format_frequency, + format_sample_count, +) def normalize_sigmf_path(filepath): @@ -658,7 +661,12 @@ def cusum(input, label, min_duration, window_size, tolerance, annotation_type, o @click.argument("input", type=click.Path(exists=True)) @click.option("--threshold", type=float, required=True, help="Threshold (0.0-1.0, fraction of max magnitude)") @click.option("--label", type=str, default=None, help="Annotation label") -@click.option("--window-size", type=int, default=None, help="Smoothing window size in samples (default: 1ms at recording sample rate)") +@click.option( + "--window-size", + type=int, + default=None, + help="Smoothing window size in samples (default: 1ms at recording sample rate)", +) @click.option( "--type", "annotation_type", diff --git a/src/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py b/src/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py index 53cf37f..b545366 100644 --- a/src/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py +++ b/src/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py @@ -18,7 +18,7 @@ from .init import init from .split import split from .transform import transform from .transmit import transmit -from .view import viewe +from .view import viewe # Aliases synth = generate diff --git a/src/ria_toolkit_oss_cli/ria_toolkit_oss/view.py b/src/ria_toolkit_oss_cli/ria_toolkit_oss/view.py index b1748f6..0a7fa62 100644 --- a/src/ria_toolkit_oss_cli/ria_toolkit_oss/view.py +++ b/src/ria_toolkit_oss_cli/ria_toolkit_oss/view.py @@ -34,13 +34,12 @@ VISUALIZATION_TYPES = { "spines", ], }, - "annotations": { + "annotations": { "function": view_annotations, "description": "Annotation-focused spectrogram view", "options": ["channel", "dark"], }, "channels": {"function": view_channels, "description": "Multi-channel IQ and spectrogram view", "options": []}, - "annotations": {"function": view_annotations, "description": "Annotated spectrogram view", "options": ["channel", "dark"]}, } From e1025794a889df056dceae5ed1440d7e96190766 Mon Sep 17 00:00:00 2001 From: madrigal Date: Thu, 2 Apr 2026 10:52:17 -0400 Subject: [PATCH 14/43] Fixed overwrite issue --- src/ria_toolkit_oss/io/recording.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ria_toolkit_oss/io/recording.py b/src/ria_toolkit_oss/io/recording.py index f56012f..3f0f403 100644 --- a/src/ria_toolkit_oss/io/recording.py +++ b/src/ria_toolkit_oss/io/recording.py @@ -337,7 +337,7 @@ def to_sigmf( meta_dict = sigMF_metafile.ordered_metadata() meta_dict["ria"] = metadata - sigMF_metafile.tofile(meta_file_path) + sigMF_metafile.tofile(meta_file_path, overwrite=overwrite) def from_sigmf(file: os.PathLike | str) -> Recording: From c7b88f1f1474d3786fd3c1681eee7a71859e36e2 Mon Sep 17 00:00:00 2001 From: madrigal Date: Thu, 2 Apr 2026 10:52:31 -0400 Subject: [PATCH 15/43] Fixed import typo --- src/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py b/src/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py index b545366..bc82fc8 100644 --- a/src/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py +++ b/src/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py @@ -18,7 +18,7 @@ from .init import init from .split import split from .transform import transform from .transmit import transmit -from .view import viewe +from .view import view # Aliases synth = generate From 1005228d69594dcd342302cb985588748c700b31 Mon Sep 17 00:00:00 2001 From: madrigal Date: Thu, 2 Apr 2026 11:07:12 -0400 Subject: [PATCH 16/43] Linting and updated poetry.lock --- poetry.lock | 102 +++++++++++++++++- .../datatypes/datasets/radio_dataset.py | 6 +- src/ria_toolkit_oss/sdr/blade.py | 12 +-- src/ria_toolkit_oss/sdr/hackrf.py | 6 +- src/ria_toolkit_oss/sdr/pluto.py | 24 ++--- src/ria_toolkit_oss/sdr/rtlsdr.py | 12 +-- src/ria_toolkit_oss/sdr/thinkrf.py | 6 +- src/ria_toolkit_oss/sdr/usrp.py | 12 +-- .../signal/block_generator/basic/add.py | 6 +- .../signal/block_generator/process_block.py | 6 +- .../block_generator/recordable_block.py | 6 +- .../source/recording_source.py | 6 +- .../ria_toolkit_oss/commands.py | 1 - 13 files changed, 135 insertions(+), 70 deletions(-) diff --git a/poetry.lock b/poetry.lock index 43af2ce..6b691a5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -896,13 +896,113 @@ files = [ [package.dependencies] numpy = ">=1.21.2" +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["test"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httptools" +version = "0.7.1" +description = "A collection of framework independent HTTP protocol utils." +optional = false +python-versions = ">=3.9" +groups = ["server", "test"] +files = [ + {file = "httptools-0.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:11d01b0ff1fe02c4c32d60af61a4d613b74fad069e47e06e9067758c01e9ac78"}, + {file = "httptools-0.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d86c1e5afdc479a6fdabf570be0d3eb791df0ae727e8dbc0259ed1249998d4"}, + {file = "httptools-0.7.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8c751014e13d88d2be5f5f14fc8b89612fcfa92a9cc480f2bc1598357a23a05"}, + {file = "httptools-0.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654968cb6b6c77e37b832a9be3d3ecabb243bbe7a0b8f65fbc5b6b04c8fcabed"}, + {file = "httptools-0.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b580968316348b474b020edf3988eecd5d6eec4634ee6561e72ae3a2a0e00a8a"}, + {file = "httptools-0.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d496e2f5245319da9d764296e86c5bb6fcf0cf7a8806d3d000717a889c8c0b7b"}, + {file = "httptools-0.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cbf8317bfccf0fed3b5680c559d3459cccf1abe9039bfa159e62e391c7270568"}, + {file = "httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657"}, + {file = "httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70"}, + {file = "httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df"}, + {file = "httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e"}, + {file = "httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274"}, + {file = "httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec"}, + {file = "httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb"}, + {file = "httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5"}, + {file = "httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5"}, + {file = "httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03"}, + {file = "httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2"}, + {file = "httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362"}, + {file = "httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c"}, + {file = "httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321"}, + {file = "httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3"}, + {file = "httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca"}, + {file = "httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c"}, + {file = "httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66"}, + {file = "httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346"}, + {file = "httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650"}, + {file = "httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6"}, + {file = "httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270"}, + {file = "httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3"}, + {file = "httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1"}, + {file = "httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b"}, + {file = "httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60"}, + {file = "httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca"}, + {file = "httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96"}, + {file = "httptools-0.7.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ac50afa68945df63ec7a2707c506bd02239272288add34539a2ef527254626a4"}, + {file = "httptools-0.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de987bb4e7ac95b99b805b99e0aae0ad51ae61df4263459d36e07cf4052d8b3a"}, + {file = "httptools-0.7.1-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d169162803a24425eb5e4d51d79cbf429fd7a491b9e570a55f495ea55b26f0bf"}, + {file = "httptools-0.7.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49794f9250188a57fa73c706b46cb21a313edb00d337ca4ce1a011fe3c760b28"}, + {file = "httptools-0.7.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:aeefa0648362bb97a7d6b5ff770bfb774930a327d7f65f8208394856862de517"}, + {file = "httptools-0.7.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0d92b10dbf0b3da4823cde6a96d18e6ae358a9daa741c71448975f6a2c339cad"}, + {file = "httptools-0.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:5ddbd045cfcb073db2449563dd479057f2c2b681ebc232380e63ef15edc9c023"}, + {file = "httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9"}, +] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["test"] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + [[package]] name = "idna" version = "3.11" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.8" -groups = ["docs"] +groups = ["agent", "docs", "server", "test"] files = [ {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, diff --git a/src/ria_toolkit_oss/datatypes/datasets/radio_dataset.py b/src/ria_toolkit_oss/datatypes/datasets/radio_dataset.py index 1ee9646..eea94c5 100644 --- a/src/ria_toolkit_oss/datatypes/datasets/radio_dataset.py +++ b/src/ria_toolkit_oss/datatypes/datasets/radio_dataset.py @@ -958,10 +958,8 @@ def get_result_sizes( # noqa: C901 # TODO: Simplify function # Check that each class that will be augmented does not already suffice target_size for cls_name, target_size_value in zip(classes_to_augment, target_size): if class_sizes[cls_name] >= target_size_value: - raise ValueError( - f"""target_size of {target_size_value} is already sufficed for current size of - {class_sizes[cls_name]} for class: {cls_name}""" - ) + raise ValueError(f"""target_size of {target_size_value} is already sufficed for current size of + {class_sizes[cls_name]} for class: {cls_name}""") for index, class_name in enumerate(classes_to_augment): result_sizes[class_name] = target_size[index] diff --git a/src/ria_toolkit_oss/sdr/blade.py b/src/ria_toolkit_oss/sdr/blade.py index c8e6f3f..576a91c 100644 --- a/src/ria_toolkit_oss/sdr/blade.py +++ b/src/ria_toolkit_oss/sdr/blade.py @@ -474,10 +474,8 @@ class Blade(SDR): if gain_mode == "relative": if gain > 0: - raise SDRParameterError( - "When gain_mode = 'relative', gain must be < 0. This sets \ - the gain relative to the maximum possible gain." - ) + raise SDRParameterError("When gain_mode = 'relative', gain must be < 0. This sets \ + the gain relative to the maximum possible gain.") else: abs_gain = rx_gain_max + gain else: @@ -550,10 +548,8 @@ class Blade(SDR): if gain_mode == "relative": if gain > 0: - raise SDRParameterError( - "When gain_mode = 'relative', gain must be < 0. This sets\ - the gain relative to the maximum possible gain." - ) + raise SDRParameterError("When gain_mode = 'relative', gain must be < 0. This sets\ + the gain relative to the maximum possible gain.") else: abs_gain = tx_gain_max + gain else: diff --git a/src/ria_toolkit_oss/sdr/hackrf.py b/src/ria_toolkit_oss/sdr/hackrf.py index 06b49a7..79e00a7 100644 --- a/src/ria_toolkit_oss/sdr/hackrf.py +++ b/src/ria_toolkit_oss/sdr/hackrf.py @@ -172,10 +172,8 @@ class HackRF(SDR): tx_gain_max = 47 if gain_mode == "relative": if gain > 0: - raise SDRParameterError( - "When gain_mode = 'relative', gain must be < 0. This \ - sets the gain relative to the maximum possible gain." - ) + raise SDRParameterError("When gain_mode = 'relative', gain must be < 0. This \ + sets the gain relative to the maximum possible gain.") else: abs_gain = tx_gain_max + gain else: diff --git a/src/ria_toolkit_oss/sdr/pluto.py b/src/ria_toolkit_oss/sdr/pluto.py index 52cd7e6..68b3973 100644 --- a/src/ria_toolkit_oss/sdr/pluto.py +++ b/src/ria_toolkit_oss/sdr/pluto.py @@ -274,20 +274,16 @@ class Pluto(SDR): data = [self._convert_tx_samples(samples), self._convert_tx_samples(samples)] else: if len(recording) > 2: - warnings.warn( - "More recordings were provided than channels in the Pluto. \ - Only the first two recordings will be used" - ) + warnings.warn("More recordings were provided than channels in the Pluto. \ + Only the first two recordings will be used") sample0 = self._convert_tx_samples(recording.data[0]) sample1 = self._convert_tx_samples(recording.data[1]) data = [sample0, sample1] elif isinstance(recording, list): if len(recording) > 2: - warnings.warn( - "More recordings were provided than channels in the Pluto. \ - Only the first two recordings will be used" - ) + warnings.warn("More recordings were provided than channels in the Pluto. \ + Only the first two recordings will be used") if isinstance(recording[0], np.ndarray): data = [self._convert_tx_samples(recording[0]), self._convert_tx_samples(recording[1])] @@ -432,10 +428,8 @@ class Pluto(SDR): if gain_mode == "relative": if gain > 0: - raise SDRParameterError( - "When gain_mode = 'relative', gain must be < 0. This sets \ - the gain relative to the maximum possible gain." - ) + raise SDRParameterError("When gain_mode = 'relative', gain must be < 0. This sets \ + the gain relative to the maximum possible gain.") else: abs_gain = rx_gain_max + gain else: @@ -545,10 +539,8 @@ class Pluto(SDR): if gain_mode == "relative": if gain > 0: - raise SDRParameterError( - "When gain_mode = 'relative', gain must be < 0. This sets\ - the gain relative to the maximum possible gain." - ) + raise SDRParameterError("When gain_mode = 'relative', gain must be < 0. This sets\ + the gain relative to the maximum possible gain.") else: abs_gain = tx_gain_max + gain else: diff --git a/src/ria_toolkit_oss/sdr/rtlsdr.py b/src/ria_toolkit_oss/sdr/rtlsdr.py index d49ab6e..bae677a 100644 --- a/src/ria_toolkit_oss/sdr/rtlsdr.py +++ b/src/ria_toolkit_oss/sdr/rtlsdr.py @@ -131,19 +131,15 @@ class RTLSDR(SDR): if gain_mode == "relative": if gain > 0: - raise SDRParameterError( - "When gain_mode = 'relative', gain must be < 0. This sets\ - the gain relative to the maximum possible gain." - ) + raise SDRParameterError("When gain_mode = 'relative', gain must be < 0. This sets\ + the gain relative to the maximum possible gain.") target_gain = max_gain + gain else: target_gain = gain if target_gain < min_gain or target_gain > max_gain: - print( - f"Requested gain {target_gain} dB out of range;\ - clamping to valid span {min_gain}-{max_gain} dB." - ) + print(f"Requested gain {target_gain} dB out of range;\ + clamping to valid span {min_gain}-{max_gain} dB.") target_gain = min(max(target_gain, min_gain), max_gain) target_gain = min(available_gains, key=lambda g: abs(g - target_gain)) diff --git a/src/ria_toolkit_oss/sdr/thinkrf.py b/src/ria_toolkit_oss/sdr/thinkrf.py index 7d108ab..adc25ff 100644 --- a/src/ria_toolkit_oss/sdr/thinkrf.py +++ b/src/ria_toolkit_oss/sdr/thinkrf.py @@ -392,10 +392,8 @@ class ThinkRF(SDR): actual_sample_rate = self.BASE_SAMPLE_RATE / decimation if abs(actual_sample_rate - requested_sample_rate) > 1e3: # More than 1 kHz difference - print( - f"ThinkRF: Requested {requested_sample_rate/1e6:.2f} MS/s → \ - Using decimation={decimation} ({actual_sample_rate/1e6:.2f} MS/s)" - ) + print(f"ThinkRF: Requested {requested_sample_rate/1e6:.2f} MS/s → \ + Using decimation={decimation} ({actual_sample_rate/1e6:.2f} MS/s)") return decimation, actual_sample_rate diff --git a/src/ria_toolkit_oss/sdr/usrp.py b/src/ria_toolkit_oss/sdr/usrp.py index f458378..70bbc46 100644 --- a/src/ria_toolkit_oss/sdr/usrp.py +++ b/src/ria_toolkit_oss/sdr/usrp.py @@ -148,10 +148,8 @@ class USRP(SDR): gain_range = self.usrp.get_rx_gain_range() if gain_mode == "relative": if gain > 0: - raise SDRParameterError( - "When gain_mode = 'relative', gain must be < 0. This sets\ - the gain relative to the maximum possible gain." - ) + raise SDRParameterError("When gain_mode = 'relative', gain must be < 0. This sets\ + the gain relative to the maximum possible gain.") else: # set gain relative to max abs_gain = gain_range.stop() + gain @@ -356,10 +354,8 @@ class USRP(SDR): gain_range = self.usrp.get_tx_gain_range() if gain_mode == "relative": if gain > 0: - raise SDRParameterError( - "When gain_mode = 'relative', gain must be < 0. This sets\ - the gain relative to the maximum possible gain." - ) + raise SDRParameterError("When gain_mode = 'relative', gain must be < 0. This sets\ + the gain relative to the maximum possible gain.") else: # set gain relative to max abs_gain = gain_range.stop() + gain diff --git a/src/ria_toolkit_oss/signal/block_generator/basic/add.py b/src/ria_toolkit_oss/signal/block_generator/basic/add.py index 9830f7b..fe59f16 100644 --- a/src/ria_toolkit_oss/signal/block_generator/basic/add.py +++ b/src/ria_toolkit_oss/signal/block_generator/basic/add.py @@ -37,10 +37,8 @@ class Add(RecordableBlock, ProcessBlock): samples = block.get_samples(num_samples) if len(samples) != num_samples: - raise ValueError( - f"Block {self.__class__.__name__} requested {num_samples} \ - from block {block.__class__.__name__} but got {len(samples)}." - ) + raise ValueError(f"Block {self.__class__.__name__} requested {num_samples} \ + from block {block.__class__.__name__} but got {len(samples)}.") return samples diff --git a/src/ria_toolkit_oss/signal/block_generator/process_block.py b/src/ria_toolkit_oss/signal/block_generator/process_block.py index 4b618cd..98eccd1 100644 --- a/src/ria_toolkit_oss/signal/block_generator/process_block.py +++ b/src/ria_toolkit_oss/signal/block_generator/process_block.py @@ -23,11 +23,9 @@ class ProcessBlock(Block, ABC): ) elif not all(isinstance(item, Block) for item in input): - raise ValueError( - f"Invalid input to block '{self.__class__.__name__}'. \ + raise ValueError(f"Invalid input to block '{self.__class__.__name__}'. \ Expected a list of Block objects but got \ - {'[' + ',' .join(f'{item.__class__.__name__}({repr(item)})' for item in input) + ']'}" - ) + {'[' + ',' .join(f'{item.__class__.__name__}({repr(item)})' for item in input) + ']'}") elif len(input) != len(self.input_type): raise ValueError( diff --git a/src/ria_toolkit_oss/signal/block_generator/recordable_block.py b/src/ria_toolkit_oss/signal/block_generator/recordable_block.py index 4b63648..28f2f2b 100644 --- a/src/ria_toolkit_oss/signal/block_generator/recordable_block.py +++ b/src/ria_toolkit_oss/signal/block_generator/recordable_block.py @@ -20,10 +20,8 @@ class RecordableBlock(Block, Recordable): :raises ValueError: If the number of samples is incorrect.""" samples = self.get_samples(num_samples) if len(samples) != num_samples: - raise ValueError( - f"Error in block {self.__class__.__name__} record(). \ - Requested {num_samples} samples but got {len(samples)}" - ) + raise ValueError(f"Error in block {self.__class__.__name__} record(). \ + Requested {num_samples} samples but got {len(samples)}") metadata = self._get_metadata() return Recording(data=samples, metadata=metadata) diff --git a/src/ria_toolkit_oss/signal/block_generator/source/recording_source.py b/src/ria_toolkit_oss/signal/block_generator/source/recording_source.py index 1b7795a..8a1e642 100644 --- a/src/ria_toolkit_oss/signal/block_generator/source/recording_source.py +++ b/src/ria_toolkit_oss/signal/block_generator/source/recording_source.py @@ -39,9 +39,7 @@ class RecordingSource(SourceBlock, RecordableBlock): :raises ValueError: If num_samples is greater than the recording length. """ if num_samples - 1 >= self.recording.data.shape[1]: - raise ValueError( - f"{num_samples} samples requested from recording source with \ - {self.recording.data.shape[1]} samples available." - ) + raise ValueError(f"{num_samples} samples requested from recording source with \ + {self.recording.data.shape[1]} samples available.") return self.recording.data[0, 0:num_samples] diff --git a/src/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py b/src/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py index 56cb3a4..174a5f4 100644 --- a/src/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py +++ b/src/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py @@ -3,7 +3,6 @@ This module contains all the CLI bindings for the ria package. """ - from .annotate import annotate from .campaign import campaign from .capture import capture From 195db4a27db894f14a70c5fc36afa8e007441026 Mon Sep 17 00:00:00 2001 From: ben Date: Tue, 14 Apr 2026 10:45:54 -0400 Subject: [PATCH 17/43] quick fix --- poetry.toml | 2 ++ src/ria_toolkit_oss/sdr/sdr.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 poetry.toml diff --git a/poetry.toml b/poetry.toml new file mode 100644 index 0000000..25758d2 --- /dev/null +++ b/poetry.toml @@ -0,0 +1,2 @@ +[virtualenvs.options] +system-site-packages = true diff --git a/src/ria_toolkit_oss/sdr/sdr.py b/src/ria_toolkit_oss/sdr/sdr.py index 36e26f7..f2ea9f4 100644 --- a/src/ria_toolkit_oss/sdr/sdr.py +++ b/src/ria_toolkit_oss/sdr/sdr.py @@ -43,6 +43,13 @@ class SDR(ABC): self.tx_gain = None self._param_lock = threading.RLock() # Reentrant lock + # Pending config consumed by rx() on first call and by _apply_sdr_config + # in the agent inference loop. Subclasses that need different defaults + # (e.g. MockSDR) can overwrite these in their own __init__. + self.center_freq: float = 2.4e9 + self.sample_rate: float = 10e6 + self.gain: float = 40.0 + def record(self, num_samples: Optional[int] = None, rx_time: Optional[int | float] = None) -> Recording: """ Create a radio recording of a given length. Either ``num_samples`` or ``rx_time`` must be provided. @@ -100,6 +107,32 @@ class SDR(ABC): self._num_buffers_processed = 0 return recording + def rx(self, num_samples: int) -> "np.ndarray": + """Return *num_samples* complex IQ samples as a 1-D complex64 array. + + This is the interface used by the agent inference loop. On first call, + ``init_rx()`` is invoked automatically using the values stored in + ``center_freq``, ``sample_rate``, and ``gain`` (set beforehand by + ``_apply_sdr_config``). Subsequent calls stream directly. + + Subclasses may override this for hardware-native capture APIs (e.g. + ``MockSDR`` uses AWGN generation; ``PlutoSDR`` could use + ``self.radio.rx()``). + """ + if not self._rx_initialized: + gain = self.gain if isinstance(self.gain, (int, float)) else 40.0 + self.init_rx( + sample_rate=self.sample_rate, + center_frequency=self.center_freq, + gain=gain, + channel=0, + ) + recording = self.record(num_samples=num_samples) + # Recording.data is either a list of 1-D arrays (one per channel) or a + # 2-D ndarray (channels × samples). Either way, index 0 is channel 0. + data = recording.data + return data[0] if hasattr(data, "__getitem__") else data + def stream_to_zmq(self, zmq_address, n_samples: int, buffer_size: Optional[int] = 10000): """ Stream iq samples as interleaved bytes via zmq. From 87bc78e063ef04f2b32c560d2db683bcb0b5eeb4 Mon Sep 17 00:00:00 2001 From: jonny Date: Tue, 14 Apr 2026 13:03:26 -0400 Subject: [PATCH 18/43] new commands --- pyproject.toml | 1 + src/ria_toolkit_oss/app/__init__.py | 1 + src/ria_toolkit_oss/app/cli.py | 242 ++++++++++++++++++++++++++++ src/ria_toolkit_oss/app/config.py | 49 ++++++ 4 files changed, 293 insertions(+) create mode 100644 src/ria_toolkit_oss/app/__init__.py create mode 100644 src/ria_toolkit_oss/app/cli.py create mode 100644 src/ria_toolkit_oss/app/config.py diff --git a/pyproject.toml b/pyproject.toml index a0bd664..3dc1fd3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,6 +118,7 @@ ria = "ria_toolkit_oss_cli.cli:cli" ria-tools = "ria_toolkit_oss_cli.cli:cli" ria-server = "ria_toolkit_oss.server.cli:serve" ria-agent = "ria_toolkit_oss.agent.cli:main" +ria-app = "ria_toolkit_oss.app.cli:main" [tool.poetry.group.server.dependencies] fastapi = ">=0.111,<1.0" diff --git a/src/ria_toolkit_oss/app/__init__.py b/src/ria_toolkit_oss/app/__init__.py new file mode 100644 index 0000000..465659f --- /dev/null +++ b/src/ria_toolkit_oss/app/__init__.py @@ -0,0 +1 @@ +"""App runner: pull and run containerized RIA applications.""" diff --git a/src/ria_toolkit_oss/app/cli.py b/src/ria_toolkit_oss/app/cli.py new file mode 100644 index 0000000..c70eb16 --- /dev/null +++ b/src/ria_toolkit_oss/app/cli.py @@ -0,0 +1,242 @@ +"""Unified ``ria-app`` CLI. + +Subcommands: + +- ``ria-app pull [:tag]`` — pull a RIA app image from the configured registry. +- ``ria-app run [:tag]`` — pull (if needed) and run, auto-configuring + GPU/USB/network flags from image labels set by CI. +- ``ria-app list`` — list locally cached RIA app images. +- ``ria-app stop `` — stop a running app container. +- ``ria-app logs `` — tail logs of a running app container. +- ``ria-app configure`` — set default registry/namespace. + +Image references resolve as:: + + my-classifier -> {registry}/{namespace}/my-classifier:latest + group/my-classifier -> {registry}/group/my-classifier:latest + host/group/app:tag -> host/group/app:tag (fully-qualified passthrough) +""" + +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +import sys + +from . import config as _config + +_LABEL_PROFILE = "ria.profile" +_LABEL_HARDWARE = "ria.hardware" +_LABEL_APP = "ria.app" + + +def _engine() -> str: + for exe in ("docker", "podman"): + if shutil.which(exe): + return exe + print("error: neither 'docker' nor 'podman' found on PATH", file=sys.stderr) + sys.exit(2) + + +def _resolve_ref(app: str, cfg: _config.AppConfig) -> str: + ref = app if ":" in app.split("/")[-1] else f"{app}:latest" + slashes = ref.count("/") + if slashes >= 2: + return ref + if slashes == 1: + return f"{cfg.registry}/{ref}" if cfg.registry else ref + if not cfg.registry or not cfg.namespace: + print( + "error: app is not fully qualified and no default registry/namespace configured. " + "Run `ria-app configure` or pass a full image reference (registry/namespace/app:tag).", + file=sys.stderr, + ) + sys.exit(2) + return f"{cfg.registry}/{cfg.namespace}/{ref}" + + +def _container_name(ref: str) -> str: + name = ref.rsplit("/", 1)[-1].split(":", 1)[0] + return f"ria-app-{name}" + + +def _inspect_labels(engine: str, ref: str) -> dict: + try: + out = subprocess.check_output( + [engine, "image", "inspect", "--format", "{{json .Config.Labels}}", ref], + stderr=subprocess.DEVNULL, + ) + except subprocess.CalledProcessError: + return {} + try: + return json.loads(out.decode().strip()) or {} + except json.JSONDecodeError: + return {} + + +def _hardware_flags(labels: dict) -> list[str]: + flags: list[str] = [] + profile = (labels.get(_LABEL_PROFILE) or "").lower() + hardware = (labels.get(_LABEL_HARDWARE) or "").lower() + hw_items = {h.strip() for h in hardware.split(",") if h.strip()} + + if "nvidia" in profile or "holoscan" in profile or "cuda" in profile: + flags += ["--gpus", "all"] + + needs_usb = hw_items & {"pluto", "rtlsdr", "hackrf", "bladerf"} + if needs_usb: + flags += ["--device", "/dev/bus/usb"] + + needs_net = hw_items & {"usrp", "thinkrf", "pluto"} + if needs_net: + flags += ["--net", "host"] + + return flags + + +def _cmd_configure(args: argparse.Namespace) -> int: + cfg = _config.load() + if args.registry: + cfg.registry = args.registry + if args.namespace: + cfg.namespace = args.namespace + path = _config.save(cfg) + print(f"Saved app config to {path}") + print(f" registry: {cfg.registry or '(unset)'}") + print(f" namespace: {cfg.namespace or '(unset)'}") + return 0 + + +def _cmd_pull(args: argparse.Namespace) -> int: + engine = _engine() + cfg = _config.load() + ref = _resolve_ref(args.app, cfg) + print(f"Pulling {ref}") + return subprocess.call([engine, "pull", ref]) + + +def _cmd_run(args: argparse.Namespace) -> int: + engine = _engine() + cfg = _config.load() + ref = _resolve_ref(args.app, cfg) + + if not _inspect_labels(engine, ref): + rc = subprocess.call([engine, "pull", ref]) + if rc != 0: + return rc + + labels = _inspect_labels(engine, ref) + hw_flags = _hardware_flags(labels) + + cmd = [engine, "run", "--rm"] + if not args.foreground: + cmd += ["-d"] + cmd += ["--name", args.name or _container_name(ref)] + cmd += hw_flags + + if args.config: + cmd += ["-v", f"{args.config}:/config/config.yaml:ro", "-e", "RIA_CONFIG=/config/config.yaml"] + + for env in args.env or []: + cmd += ["-e", env] + for vol in args.volume or []: + cmd += ["-v", vol] + for port in args.publish or []: + cmd += ["-p", port] + + cmd += list(args.docker_args or []) + cmd += [ref] + cmd += list(args.app_args or []) + + if args.dry_run: + print(" ".join(cmd)) + return 0 + + label_str = ", ".join(f"{k}={v}" for k, v in labels.items() if k.startswith("ria.")) or "(no ria.* labels)" + print(f"Running {ref} [{label_str}]") + if hw_flags: + print(f" auto flags: {' '.join(hw_flags)}") + return subprocess.call(cmd) + + +def _cmd_list(_args: argparse.Namespace) -> int: + engine = _engine() + return subprocess.call( + [ + engine, + "images", + "--filter", + f"label={_LABEL_APP}", + "--format", + "table {{.Repository}}:{{.Tag}}\t{{.ID}}\t{{.Size}}", + ] + ) + + +def _cmd_stop(args: argparse.Namespace) -> int: + engine = _engine() + name = args.name or _container_name(_resolve_ref(args.app, _config.load())) + return subprocess.call([engine, "stop", name]) + + +def _cmd_logs(args: argparse.Namespace) -> int: + engine = _engine() + name = args.name or _container_name(_resolve_ref(args.app, _config.load())) + cmd = [engine, "logs"] + if args.follow: + cmd += ["-f"] + cmd += [name] + return subprocess.call(cmd) + + +def main() -> None: + parser = argparse.ArgumentParser(prog="ria-app") + sub = parser.add_subparsers(dest="command", required=True) + + p_cfg = sub.add_parser("configure", help="Set default registry/namespace") + p_cfg.add_argument("--registry", default=None, help="Default container registry (e.g. registry.riahub.ai)") + p_cfg.add_argument("--namespace", default=None, help="Default namespace (e.g. qoherent)") + + p_pull = sub.add_parser("pull", help="Pull an app image") + p_pull.add_argument("app", help="App name or image reference") + + p_run = sub.add_parser("run", help="Run an app, auto-detecting hardware flags") + p_run.add_argument("app", help="App name or image reference") + p_run.add_argument("--name", default=None, help="Container name (default: ria-app-)") + p_run.add_argument("--config", default=None, help="Path to config.yaml to mount into the container") + p_run.add_argument("-e", "--env", action="append", help="Extra env var (KEY=VALUE)") + p_run.add_argument("-v", "--volume", action="append", help="Extra volume mount") + p_run.add_argument("-p", "--publish", action="append", help="Publish port") + p_run.add_argument("--foreground", "-F", action="store_true", help="Run in foreground (no -d)") + p_run.add_argument("--dry-run", action="store_true", help="Print the container command and exit") + p_run.add_argument("--docker-args", nargs=argparse.REMAINDER, help="Pass remaining args to docker/podman run") + p_run.add_argument("--app-args", nargs=argparse.REMAINDER, help="Pass remaining args to the app entrypoint") + + sub.add_parser("list", help="List locally cached RIA app images") + + p_stop = sub.add_parser("stop", help="Stop a running app") + p_stop.add_argument("app", help="App name or image reference") + p_stop.add_argument("--name", default=None, help="Container name override") + + p_logs = sub.add_parser("logs", help="Tail logs of a running app") + p_logs.add_argument("app", help="App name or image reference") + p_logs.add_argument("--name", default=None, help="Container name override") + p_logs.add_argument("-f", "--follow", action="store_true", help="Follow log output") + + args = parser.parse_args() + + dispatch = { + "configure": _cmd_configure, + "pull": _cmd_pull, + "run": _cmd_run, + "list": _cmd_list, + "stop": _cmd_stop, + "logs": _cmd_logs, + } + sys.exit(dispatch[args.command](args)) + + +if __name__ == "__main__": + main() diff --git a/src/ria_toolkit_oss/app/config.py b/src/ria_toolkit_oss/app/config.py new file mode 100644 index 0000000..2594761 --- /dev/null +++ b/src/ria_toolkit_oss/app/config.py @@ -0,0 +1,49 @@ +"""App runner configuration at ``~/.ria/toolkit.json``. + +Schema:: + + { + "registry": "registry.riahub.ai", + "namespace": "qoherent" + } +""" + +from __future__ import annotations + +import json +import os +from dataclasses import asdict, dataclass +from pathlib import Path + +_DEFAULT_PATH = Path(os.environ.get("RIA_TOOLKIT_CONFIG", str(Path.home() / ".ria" / "toolkit.json"))) + + +@dataclass +class AppConfig: + registry: str = "" + namespace: str = "" + + +def default_path() -> Path: + return _DEFAULT_PATH + + +def load(path: Path | None = None) -> AppConfig: + p = path or _DEFAULT_PATH + if not p.exists(): + return AppConfig( + registry=os.environ.get("RIA_REGISTRY", ""), + namespace=os.environ.get("RIA_NAMESPACE", ""), + ) + data = json.loads(p.read_text()) + return AppConfig( + registry=data.get("registry", "") or os.environ.get("RIA_REGISTRY", ""), + namespace=data.get("namespace", "") or os.environ.get("RIA_NAMESPACE", ""), + ) + + +def save(cfg: AppConfig, path: Path | None = None) -> Path: + p = path or _DEFAULT_PATH + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(json.dumps(asdict(cfg), indent=2)) + return p From 20fe86d399fa9c11e8904c3aa1eb2828a7ac9e39 Mon Sep 17 00:00:00 2001 From: jonny Date: Tue, 14 Apr 2026 13:18:34 -0400 Subject: [PATCH 19/43] allow sudo calls --- src/ria_toolkit_oss/app/cli.py | 51 ++++++++++++++++++++----------- src/ria_toolkit_oss/app/config.py | 2 ++ 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/src/ria_toolkit_oss/app/cli.py b/src/ria_toolkit_oss/app/cli.py index c70eb16..6cd0c1c 100644 --- a/src/ria_toolkit_oss/app/cli.py +++ b/src/ria_toolkit_oss/app/cli.py @@ -32,10 +32,11 @@ _LABEL_HARDWARE = "ria.hardware" _LABEL_APP = "ria.app" -def _engine() -> str: +def _engine(cfg: _config.AppConfig, sudo_override: bool = False) -> list[str]: for exe in ("docker", "podman"): if shutil.which(exe): - return exe + use_sudo = sudo_override or cfg.sudo + return (["sudo", exe] if use_sudo else [exe]) print("error: neither 'docker' nor 'podman' found on PATH", file=sys.stderr) sys.exit(2) @@ -62,10 +63,10 @@ def _container_name(ref: str) -> str: return f"ria-app-{name}" -def _inspect_labels(engine: str, ref: str) -> dict: +def _inspect_labels(engine: list[str], ref: str) -> dict: try: out = subprocess.check_output( - [engine, "image", "inspect", "--format", "{{json .Config.Labels}}", ref], + [*engine, "image", "inspect", "--format", "{{json .Config.Labels}}", ref], stderr=subprocess.DEVNULL, ) except subprocess.CalledProcessError: @@ -102,35 +103,38 @@ def _cmd_configure(args: argparse.Namespace) -> int: cfg.registry = args.registry if args.namespace: cfg.namespace = args.namespace + if args.sudo is not None: + cfg.sudo = args.sudo path = _config.save(cfg) print(f"Saved app config to {path}") print(f" registry: {cfg.registry or '(unset)'}") print(f" namespace: {cfg.namespace or '(unset)'}") + print(f" sudo: {cfg.sudo}") return 0 def _cmd_pull(args: argparse.Namespace) -> int: - engine = _engine() cfg = _config.load() + engine = _engine(cfg, args.sudo) ref = _resolve_ref(args.app, cfg) print(f"Pulling {ref}") - return subprocess.call([engine, "pull", ref]) + return subprocess.call([*engine, "pull", ref]) def _cmd_run(args: argparse.Namespace) -> int: - engine = _engine() cfg = _config.load() + engine = _engine(cfg, args.sudo) ref = _resolve_ref(args.app, cfg) if not _inspect_labels(engine, ref): - rc = subprocess.call([engine, "pull", ref]) + rc = subprocess.call([*engine, "pull", ref]) if rc != 0: return rc labels = _inspect_labels(engine, ref) hw_flags = _hardware_flags(labels) - cmd = [engine, "run", "--rm"] + cmd = [*engine, "run", "--rm"] if not args.foreground: cmd += ["-d"] cmd += ["--name", args.name or _container_name(ref)] @@ -161,11 +165,12 @@ def _cmd_run(args: argparse.Namespace) -> int: return subprocess.call(cmd) -def _cmd_list(_args: argparse.Namespace) -> int: - engine = _engine() +def _cmd_list(args: argparse.Namespace) -> int: + cfg = _config.load() + engine = _engine(cfg, args.sudo) return subprocess.call( [ - engine, + *engine, "images", "--filter", f"label={_LABEL_APP}", @@ -176,15 +181,17 @@ def _cmd_list(_args: argparse.Namespace) -> int: def _cmd_stop(args: argparse.Namespace) -> int: - engine = _engine() - name = args.name or _container_name(_resolve_ref(args.app, _config.load())) - return subprocess.call([engine, "stop", name]) + cfg = _config.load() + engine = _engine(cfg, args.sudo) + name = args.name or _container_name(_resolve_ref(args.app, cfg)) + return subprocess.call([*engine, "stop", name]) def _cmd_logs(args: argparse.Namespace) -> int: - engine = _engine() - name = args.name or _container_name(_resolve_ref(args.app, _config.load())) - cmd = [engine, "logs"] + cfg = _config.load() + engine = _engine(cfg, args.sudo) + name = args.name or _container_name(_resolve_ref(args.app, cfg)) + cmd = [*engine, "logs"] if args.follow: cmd += ["-f"] cmd += [name] @@ -193,11 +200,19 @@ def _cmd_logs(args: argparse.Namespace) -> int: def main() -> None: parser = argparse.ArgumentParser(prog="ria-app") + parser.add_argument("--sudo", action="store_true", default=False, help="Run docker/podman via sudo") sub = parser.add_subparsers(dest="command", required=True) p_cfg = sub.add_parser("configure", help="Set default registry/namespace") p_cfg.add_argument("--registry", default=None, help="Default container registry (e.g. registry.riahub.ai)") p_cfg.add_argument("--namespace", default=None, help="Default namespace (e.g. qoherent)") + p_cfg.add_argument( + "--sudo", + dest="sudo", + action=argparse.BooleanOptionalAction, + default=None, + help="Persist sudo default (--sudo / --no-sudo)", + ) p_pull = sub.add_parser("pull", help="Pull an app image") p_pull.add_argument("app", help="App name or image reference") diff --git a/src/ria_toolkit_oss/app/config.py b/src/ria_toolkit_oss/app/config.py index 2594761..8bff807 100644 --- a/src/ria_toolkit_oss/app/config.py +++ b/src/ria_toolkit_oss/app/config.py @@ -22,6 +22,7 @@ _DEFAULT_PATH = Path(os.environ.get("RIA_TOOLKIT_CONFIG", str(Path.home() / ".ri class AppConfig: registry: str = "" namespace: str = "" + sudo: bool = False def default_path() -> Path: @@ -39,6 +40,7 @@ def load(path: Path | None = None) -> AppConfig: return AppConfig( registry=data.get("registry", "") or os.environ.get("RIA_REGISTRY", ""), namespace=data.get("namespace", "") or os.environ.get("RIA_NAMESPACE", ""), + sudo=bool(data.get("sudo", False)) or os.environ.get("RIA_DOCKER_SUDO", "") not in ("", "0", "false"), ) From b955256479f21bebd4594b373a19b31f359bffee Mon Sep 17 00:00:00 2001 From: jonny Date: Thu, 16 Apr 2026 11:13:43 -0400 Subject: [PATCH 20/43] Pluto TX streaming functionality base --- src/ria_toolkit_oss/agent/cli.py | 62 ++- src/ria_toolkit_oss/agent/config.py | 33 +- src/ria_toolkit_oss/agent/hardware.py | 26 +- src/ria_toolkit_oss/agent/streamer.py | 607 ++++++++++++++++++++++--- src/ria_toolkit_oss/agent/ws_client.py | 17 +- src/ria_toolkit_oss/app/cli.py | 37 +- tests/agent/test_cli_tx.py | 111 +++++ tests/agent/test_config.py | 30 ++ tests/agent/test_disconnect.py | 8 +- tests/agent/test_full_duplex.py | 133 ++++++ tests/agent/test_hardware.py | 17 + tests/agent/test_integration_tx.py | 144 ++++++ tests/agent/test_streamer.py | 88 +++- tests/agent/test_streamer_tx.py | 133 ++++++ tests/agent/test_tx_safety.py | 167 +++++++ tests/agent/test_tx_underrun.py | 136 ++++++ tests/agent/test_ws_client.py | 103 +++++ 17 files changed, 1752 insertions(+), 100 deletions(-) create mode 100644 tests/agent/test_cli_tx.py create mode 100644 tests/agent/test_full_duplex.py create mode 100644 tests/agent/test_integration_tx.py create mode 100644 tests/agent/test_streamer_tx.py create mode 100644 tests/agent/test_tx_safety.py create mode 100644 tests/agent/test_tx_underrun.py diff --git a/src/ria_toolkit_oss/agent/cli.py b/src/ria_toolkit_oss/agent/cli.py index 0b06e72..6b88473 100644 --- a/src/ria_toolkit_oss/agent/cli.py +++ b/src/ria_toolkit_oss/agent/cli.py @@ -5,8 +5,8 @@ Subcommands: - ``ria-agent run [legacy args]`` — legacy long-poll NodeAgent (unchanged). - ``ria-agent stream`` — new WebSocket-based IQ streamer. - ``ria-agent detect`` — print SDR drivers whose modules import cleanly. -- ``ria-agent register --url URL --token TOKEN`` — save credentials to - ``~/.ria/agent.json``. +- ``ria-agent register --hub URL --api-key KEY`` — register with the hub and + save credentials (and optional TX interlocks) to ``~/.ria/agent.json``. Invoking ``ria-agent`` with no subcommand falls through to the legacy long-poll behavior for back-compatibility with existing deployments. @@ -69,9 +69,27 @@ def _cmd_register(args: argparse.Namespace) -> int: if args.name: cfg.name = args.name cfg.insecure = bool(args.insecure) + cfg.tx_enabled = bool(getattr(args, "allow_tx", False)) + if (v := getattr(args, "tx_max_gain_db", None)) is not None: + cfg.tx_max_gain_db = float(v) + if (v := getattr(args, "tx_max_duration_s", None)) is not None: + cfg.tx_max_duration_s = float(v) + freq_ranges = getattr(args, "tx_freq_range", None) or [] + if freq_ranges: + cfg.tx_allowed_freq_ranges = [[float(lo), float(hi)] for lo, hi in freq_ranges] path = _config.save(cfg) print(f"Registered agent: {agent_id}") + if cfg.tx_enabled: + caps: list[str] = [] + if cfg.tx_max_gain_db is not None: + caps.append(f"gain<={cfg.tx_max_gain_db} dB") + if cfg.tx_max_duration_s is not None: + caps.append(f"duration<={cfg.tx_max_duration_s} s") + if cfg.tx_allowed_freq_ranges: + caps.append(f"freq in {cfg.tx_allowed_freq_ranges}") + tail = f" ({', '.join(caps)})" if caps else "" + print(f"TX enabled{tail}") print(f"Credentials saved to {path}") return 0 @@ -85,8 +103,10 @@ def _cmd_stream(args: argparse.Namespace) -> int: if not url: print("error: --url is required (or run `ria-agent register` first)", file=sys.stderr) return 2 + if getattr(args, "allow_tx", False): + cfg.tx_enabled = True try: - asyncio.run(run_streamer(url, token)) + asyncio.run(run_streamer(url, token, cfg=cfg)) except KeyboardInterrupt: pass return 0 @@ -123,11 +143,47 @@ def main() -> None: p_reg.add_argument("--api-key", dest="api_key", required=True, help="Hub API key") p_reg.add_argument("--name", default=None, help="Human-friendly agent name") p_reg.add_argument("--insecure", action="store_true", help="Skip TLS verification") + p_reg.add_argument( + "--allow-tx", + dest="allow_tx", + action="store_true", + help="Opt this agent in to TX (required for any transmission from the hub)", + ) + p_reg.add_argument( + "--tx-max-gain-db", + dest="tx_max_gain_db", + type=float, + default=None, + help="Reject tx_start frames whose tx_gain exceeds this cap (dB)", + ) + p_reg.add_argument( + "--tx-max-duration-s", + dest="tx_max_duration_s", + type=float, + default=None, + help="Auto-stop any TX session after this many seconds", + ) + p_reg.add_argument( + "--tx-freq-range", + dest="tx_freq_range", + type=float, + nargs=2, + action="append", + metavar=("LO", "HI"), + default=None, + help="Allowed TX center-frequency range in Hz (repeat for multiple bands)", + ) p_stream = sub.add_parser("stream", help="Run the WebSocket IQ streamer") p_stream.add_argument("--url", default=None, help="Override WebSocket URL") p_stream.add_argument("--token", default=None, help="Override bearer token") p_stream.add_argument("--log-level", default="INFO") + p_stream.add_argument( + "--allow-tx", + dest="allow_tx", + action="store_true", + help="Runtime override: enable TX for this process without writing config", + ) # Unknown extras are forwarded to the legacy CLI when command == "run". args, extras = parser.parse_known_args(argv) diff --git a/src/ria_toolkit_oss/agent/config.py b/src/ria_toolkit_oss/agent/config.py index d1f0e00..431094a 100644 --- a/src/ria_toolkit_oss/agent/config.py +++ b/src/ria_toolkit_oss/agent/config.py @@ -7,7 +7,11 @@ Schema:: "agent_id": "agent-abc123", "token": "rha_xxxx", "name": "lab-bench-1", - "insecure": false + "insecure": false, + "tx_enabled": false, + "tx_max_gain_db": null, + "tx_max_duration_s": null, + "tx_allowed_freq_ranges": null } """ @@ -18,7 +22,8 @@ import os from dataclasses import asdict, dataclass, field from pathlib import Path -_DEFAULT_PATH = Path(os.environ.get("RIA_AGENT_CONFIG", str(Path.home() / ".ria" / "agent.json"))) +def _resolve_default_path() -> Path: + return Path(os.environ.get("RIA_AGENT_CONFIG", str(Path.home() / ".ria" / "agent.json"))) @dataclass @@ -29,15 +34,29 @@ class AgentConfig: name: str = "" insecure: bool = False api_key: str = "" + tx_enabled: bool = False + tx_max_gain_db: float | None = None + tx_max_duration_s: float | None = None + tx_allowed_freq_ranges: list[list[float]] | None = None extra: dict = field(default_factory=dict) def default_path() -> Path: - return _DEFAULT_PATH + return _resolve_default_path() + + +def _coerce_ranges(raw) -> list[list[float]] | None: + if raw is None: + return None + out: list[list[float]] = [] + for pair in raw: + lo, hi = pair + out.append([float(lo), float(hi)]) + return out def load(path: Path | None = None) -> AgentConfig: - p = path or _DEFAULT_PATH + p = path or _resolve_default_path() if not p.exists(): return AgentConfig() data = json.loads(p.read_text()) @@ -50,12 +69,16 @@ def load(path: Path | None = None) -> AgentConfig: name=data.get("name", ""), insecure=bool(data.get("insecure", False)), api_key=data.get("api_key", ""), + tx_enabled=bool(data.get("tx_enabled", False)), + tx_max_gain_db=(float(v) if (v := data.get("tx_max_gain_db")) is not None else None), + tx_max_duration_s=(float(v) if (v := data.get("tx_max_duration_s")) is not None else None), + tx_allowed_freq_ranges=_coerce_ranges(data.get("tx_allowed_freq_ranges")), extra=extra, ) def save(cfg: AgentConfig, path: Path | None = None) -> Path: - p = path or _DEFAULT_PATH + p = path or _resolve_default_path() p.parent.mkdir(parents=True, exist_ok=True) data = asdict(cfg) extra = data.pop("extra", {}) or {} diff --git a/src/ria_toolkit_oss/agent/hardware.py b/src/ria_toolkit_oss/agent/hardware.py index 417bf1c..32a65e5 100644 --- a/src/ria_toolkit_oss/agent/hardware.py +++ b/src/ria_toolkit_oss/agent/hardware.py @@ -4,19 +4,41 @@ from __future__ import annotations from ria_toolkit_oss.sdr import detect_available +from .config import AgentConfig + def available_devices() -> list[str]: """Return a sorted list of device names whose driver modules import cleanly.""" return sorted(detect_available().keys()) -def heartbeat_payload(status: str = "idle", app_id: str | None = None) -> dict: - """Build the JSON body of a periodic heartbeat frame.""" +def heartbeat_payload( + status: str = "idle", + app_id: str | None = None, + *, + cfg: AgentConfig | None = None, + sessions: dict | None = None, +) -> dict: + """Build the JSON body of a periodic heartbeat frame. + + *cfg* drives the ``capabilities`` list and the ``tx_enabled`` flag. If not + supplied, the heartbeat advertises RX-only with ``tx_enabled=False`` — + matching the pre-TX shape. + """ + c = cfg or AgentConfig() + capabilities = ["rx"] + if c.tx_enabled: + capabilities.append("tx") + payload: dict = { "type": "heartbeat", "hardware": available_devices(), "status": status, + "capabilities": capabilities, + "tx_enabled": bool(c.tx_enabled), } if app_id: payload["app_id"] = app_id + if sessions: + payload["sessions"] = sessions return payload diff --git a/src/ria_toolkit_oss/agent/streamer.py b/src/ria_toolkit_oss/agent/streamer.py index 4d89743..8570a73 100644 --- a/src/ria_toolkit_oss/agent/streamer.py +++ b/src/ria_toolkit_oss/agent/streamer.py @@ -1,20 +1,33 @@ -"""Thin IQ-streaming agent. +"""IQ-streaming agent. Listens for control messages from the RIA Hub over a persistent WebSocket. -When the server sends ``start``, opens the SDR described in ``radio_config``, -loops over ``sdr.rx(buffer_size)``, and sends each buffer as raw -interleaved float32 bytes. ``stop`` closes the SDR; ``configure`` applies -parameter updates at the next capture boundary. +Supports: + +- An **RX session** (hub sends ``start``/``stop``/``configure``; agent opens + the SDR, loops ``sdr.rx()`` and ships raw interleaved float32 IQ). +- A **TX session** (hub sends ``tx_start``/``tx_stop``/``tx_configure`` plus + binary IQ frames; agent feeds them into ``sdr._stream_tx``). Phase 3 wires + up the session plumbing and rejects TX when ``cfg.tx_enabled`` is False; + Phase 4 implements the full TX loop. + +Both sessions can run concurrently on the same physical SDR (FDD) — a +ref-counted SDR registry shares one driver instance when RX and TX name the +same ``(device, identifier)``. """ from __future__ import annotations import asyncio import logging +import queue +import threading +import time +from dataclasses import dataclass, field from typing import Any import numpy as np +from .config import AgentConfig from .hardware import heartbeat_payload from .ws_client import WsClient @@ -23,6 +36,98 @@ logger = logging.getLogger("ria_agent.streamer") _DEFAULT_BUFFER_SIZE = 1024 +# --------------------------------------------------------------------------- +# Session dataclasses + + +@dataclass +class RxSession: + app_id: str + sdr: Any + device_key: tuple[str, str | None] + buffer_size: int + task: asyncio.Task | None = None + pending_config: dict = field(default_factory=dict) + + +@dataclass +class TxSession: + app_id: str + sdr: Any + device_key: tuple[str, str | None] + buffer_size: int + task: Any = None # concurrent.futures.Future from run_in_executor + pending_config: dict = field(default_factory=dict) + underrun_policy: str = "pause" + last_buffer: np.ndarray | None = None + stop_event: threading.Event = field(default_factory=threading.Event) + started_at: float = 0.0 + max_duration_s: float | None = None + state: str = "armed" + # Thread-safe queue of inbound interleaved-float32 IQ frames. Bounded so + # hub-side over-production triggers WS backpressure rather than memory + # growth in the agent. + in_queue: "queue.Queue[bytes]" = field(default_factory=lambda: queue.Queue(maxsize=8)) + # Set by the TX callback when it hits an underrun while policy=="pause"; + # asyncio side flips the session state and emits tx_status. + underrun_flag: threading.Event = field(default_factory=threading.Event) + + +# --------------------------------------------------------------------------- +# SDR registry (ref-counted so one Pluto handle serves RX + TX simultaneously) + + +class _SdrRegistry: + def __init__(self, factory): + self._factory = factory + self._instances: dict[tuple[str, str | None], tuple[Any, int]] = {} + self._lock = threading.Lock() + + def acquire(self, device: str, identifier: str | None) -> tuple[Any, tuple[str, str | None]]: + key = (device, identifier) + with self._lock: + if key in self._instances: + sdr, rc = self._instances[key] + self._instances[key] = (sdr, rc + 1) + return sdr, key + # Build outside the lock: driver init can be slow and we don't want to + # block concurrent releases on unrelated devices. + sdr = self._factory(device, identifier) + with self._lock: + if key in self._instances: + # Raced another acquirer; discard our duplicate and share theirs. + other_sdr, rc = self._instances[key] + try: + sdr.close() + except Exception: + pass + self._instances[key] = (other_sdr, rc + 1) + return other_sdr, key + self._instances[key] = (sdr, 1) + return sdr, key + + def release(self, key: tuple[str, str | None]) -> bool: + """Decrement refcount. Returns True if the caller owns the last reference + and should close the SDR.""" + with self._lock: + sdr, rc = self._instances.get(key, (None, 0)) + if sdr is None: + return False + if rc <= 1: + del self._instances[key] + return True + self._instances[key] = (sdr, rc - 1) + return False + + def refcount(self, key: tuple[str, str | None]) -> int: + with self._lock: + return self._instances.get(key, (None, 0))[1] + + +# --------------------------------------------------------------------------- +# Streamer + + class Streamer: """Main streamer loop. @@ -31,103 +136,186 @@ class Streamer: ws: Connected :class:`WsClient`. sdr_factory: - Callable ``(device, identifier) -> SDR``. Defaults to - :func:`ria_toolkit_oss.sdr.get_sdr_device`. Injectable for tests. + Callable ``(device, identifier) -> SDR``. Defaults to the helper in + :mod:`ria_toolkit_oss.sdr`. Injectable for tests. + cfg: + :class:`AgentConfig` for interlocks (``tx_enabled`` and caps) and + heartbeat capabilities. Defaults to an empty ``AgentConfig()`` which + leaves TX disabled. """ - def __init__(self, ws: WsClient, sdr_factory=None) -> None: + def __init__( + self, + ws, + sdr_factory=None, + cfg: AgentConfig | None = None, + ) -> None: self.ws = ws - self._sdr_factory = sdr_factory - self._app_id: str | None = None - self._sdr: Any = None - self._pending_config: dict = {} - self._capture_task: asyncio.Task | None = None - self._status = "idle" + self._cfg = cfg or AgentConfig() + self._registry = _SdrRegistry(sdr_factory or _default_sdr_factory) + self._rx: RxSession | None = None + self._tx: TxSession | None = None + # Pending radio_config accepted via ``configure`` before ``start``. + self._standalone_pending_config: dict = {} + # Cached asyncio event loop, set the first time a handler runs. Used + # to schedule async callbacks from the TX executor thread. + self._loop: asyncio.AbstractEventLoop | None = None + + # ------------------------------------------------------------------ + # Back-compat read-only shims for callers that check ``._sdr`` etc. + # Writes to these attributes are not supported — use the session objects. + + @property + def _sdr(self): + return self._rx.sdr if self._rx is not None else None + + @property + def _pending_config(self) -> dict: + return self._rx.pending_config if self._rx is not None else self._standalone_pending_config # ------------------------------------------------------------------ # WsClient wiring def build_heartbeat(self) -> dict: - return heartbeat_payload(status=self._status, app_id=self._app_id) + status = "streaming" if (self._rx is not None or self._tx is not None) else "idle" + app_id: str | None = None + if self._rx is not None: + app_id = self._rx.app_id + elif self._tx is not None: + app_id = self._tx.app_id + + sessions: dict[str, dict] = {} + if self._rx is not None: + sessions["rx"] = {"app_id": self._rx.app_id, "state": "streaming"} + if self._tx is not None: + sessions["tx"] = {"app_id": self._tx.app_id, "state": self._tx.state} + + return heartbeat_payload( + status=status, + app_id=app_id, + cfg=self._cfg, + sessions=sessions or None, + ) async def on_message(self, msg: dict) -> None: t = msg.get("type") - if t == "start": - await self._handle_start(msg) - elif t == "stop": - await self._handle_stop(msg) - elif t == "configure": - self._pending_config.update(msg.get("radio_config") or {}) - logger.debug("Queued configure: %s", self._pending_config) - else: + handler = { + "start": self._handle_rx_start, + "stop": self._handle_rx_stop, + "configure": self._handle_rx_configure, + "tx_start": self._handle_tx_start, + "tx_stop": self._handle_tx_stop, + "tx_configure": self._handle_tx_configure, + }.get(t) + if handler is None: logger.warning("Unknown server message type: %r", t) + return + await handler(msg) - # ------------------------------------------------------------------ - async def _handle_start(self, msg: dict) -> None: - if self._capture_task is not None and not self._capture_task.done(): + async def on_binary(self, data: bytes) -> None: + tx = self._tx + if tx is None: + logger.debug("Dropping %d-byte binary frame: no TX session", len(data)) + return + # Backpressure: if the TX queue is full, await briefly so the hub's + # ``await ws.send`` throttles naturally via TCP. We don't block + # indefinitely — a 2s stall means something else is wrong. + loop = asyncio.get_running_loop() + try: + await loop.run_in_executor(None, lambda: tx.in_queue.put(data, timeout=2.0)) + except queue.Full: + logger.warning("TX queue stalled; dropping frame") + + # ================================================================== + # RX + + async def _handle_rx_start(self, msg: dict) -> None: + if self._rx is not None: logger.warning("start received while already streaming — ignoring") return - self._app_id = msg.get("app_id") + app_id = msg.get("app_id") or "" radio_config = dict(msg.get("radio_config") or {}) device = radio_config.pop("device", None) identifier = radio_config.pop("identifier", None) buffer_size = int(radio_config.pop("buffer_size", _DEFAULT_BUFFER_SIZE)) if not device: - await self._send_error("start missing radio_config.device") + await self._send_error(app_id, "start missing radio_config.device") return try: - factory = self._sdr_factory or _default_sdr_factory - self._sdr = factory(device, identifier) - _apply_sdr_config(self._sdr, radio_config) + sdr, device_key = self._registry.acquire(device, identifier) + _apply_sdr_config(sdr, radio_config) except Exception as exc: logger.exception("Failed to open SDR %r", device) - await self._send_error(f"SDR init failed: {exc}") + await self._send_error(app_id, f"SDR init failed: {exc}") return - self._status = "streaming" - await self._send_status("streaming") - self._capture_task = asyncio.create_task( - self._capture_loop(buffer_size), name="ria-streamer-capture" + # Inherit any pending config that was queued before start. + pending = dict(self._standalone_pending_config) + self._standalone_pending_config = {} + + session = RxSession( + app_id=app_id, + sdr=sdr, + device_key=device_key, + buffer_size=buffer_size, + pending_config=pending, + ) + self._rx = session + await self._send_status("streaming", app_id) + session.task = asyncio.create_task( + self._capture_loop(session), name="ria-streamer-capture" ) - async def _handle_stop(self, msg: dict) -> None: - if self._capture_task is not None: - self._capture_task.cancel() + async def _handle_rx_stop(self, msg: dict) -> None: + session = self._rx + if session is None: + return + if session.task is not None: + session.task.cancel() try: - await self._capture_task + await session.task except (asyncio.CancelledError, Exception): pass - self._capture_task = None - self._close_sdr() - self._app_id = None - self._status = "idle" - await self._send_status("idle") + self._close_session_sdr(session) + app_id = session.app_id + self._rx = None + await self._send_status("idle", app_id) - async def _capture_loop(self, buffer_size: int) -> None: + async def _handle_rx_configure(self, msg: dict) -> None: + cfg = dict(msg.get("radio_config") or {}) + if self._rx is not None: + self._rx.pending_config.update(cfg) + else: + self._standalone_pending_config.update(cfg) + logger.debug("Queued configure: %s", cfg) + + async def _capture_loop(self, session: RxSession) -> None: loop = asyncio.get_running_loop() try: while True: - if self._pending_config: - cfg = self._pending_config - self._pending_config = {} + if session.pending_config: + cfg = session.pending_config + session.pending_config = {} try: - _apply_sdr_config(self._sdr, cfg) + _apply_sdr_config(session.sdr, cfg) except Exception as exc: logger.warning("Applying configure failed: %s", exc) try: - samples = await loop.run_in_executor(None, self._sdr.rx, buffer_size) + samples = await loop.run_in_executor( + None, session.sdr.rx, session.buffer_size + ) except Exception as exc: from ria_toolkit_oss.sdr import SdrDisconnectedError if isinstance(exc, SdrDisconnectedError): logger.warning("SDR disconnected: %s", exc) - await self._send_error(f"SDR disconnected: {exc}") + await self._send_error(session.app_id, f"SDR disconnected: {exc}") else: logger.exception("SDR rx error") - await self._send_error(f"SDR capture failed: {exc}") + await self._send_error(session.app_id, f"SDR capture failed: {exc}") break payload = _samples_to_interleaved_float32(samples) @@ -139,29 +327,305 @@ class Streamer: except asyncio.CancelledError: raise finally: - self._close_sdr() + self._close_session_sdr(session) + # If the loop died on its own (e.g. SDR disconnect), clear the + # session handle so future ``start`` messages can proceed. + if self._rx is session: + self._rx = None - def _close_sdr(self) -> None: - if self._sdr is None: + # ================================================================== + # TX + + async def _handle_tx_start(self, msg: dict) -> None: + app_id = msg.get("app_id") or "" + radio_config = dict(msg.get("radio_config") or {}) + + # --- interlocks (agent-enforced; never trust the hub alone) --- + if not self._cfg.tx_enabled: + await self._send_tx_status(app_id, "error", "tx disabled on this agent") return + tx_gain = radio_config.get("tx_gain") + if ( + self._cfg.tx_max_gain_db is not None + and tx_gain is not None + and float(tx_gain) > float(self._cfg.tx_max_gain_db) + ): + await self._send_tx_status( + app_id, + "error", + f"tx_gain {tx_gain} exceeds cap {self._cfg.tx_max_gain_db}", + ) + return + tx_freq = radio_config.get("tx_center_frequency") + if self._cfg.tx_allowed_freq_ranges and tx_freq is not None: + f = float(tx_freq) + if not any(float(lo) <= f <= float(hi) for lo, hi in self._cfg.tx_allowed_freq_ranges): + await self._send_tx_status( + app_id, + "error", + f"tx_center_frequency {tx_freq} outside allowed ranges", + ) + return + + if self._tx is not None: + await self._send_tx_status(app_id, "error", "tx already active on this agent") + return + + # --- device --- + device = radio_config.pop("device", None) + identifier = radio_config.pop("identifier", None) + buffer_size = int(radio_config.pop("buffer_size", _DEFAULT_BUFFER_SIZE)) + underrun_policy = str(radio_config.pop("underrun_policy", "pause")) + if underrun_policy not in ("pause", "zero", "repeat"): + await self._send_tx_status( + app_id, "error", f"invalid underrun_policy {underrun_policy!r}" + ) + return + if not device: + await self._send_tx_status(app_id, "error", "tx_start missing radio_config.device") + return + + device_key: tuple[str, str | None] | None = None + sdr: Any = None try: - self._sdr.close() + sdr, device_key = self._registry.acquire(device, identifier) + _apply_sdr_config(sdr, radio_config) + # Only call init_tx when the hub supplied the three required + # parameters. Drivers that gate _stream_tx on _tx_initialized + # (e.g. Pluto) need this; drivers that don't (e.g. Mock) tolerate + # its absence. + init_args = { + k: radio_config.get(f"tx_{k}") + for k in ("sample_rate", "center_frequency", "gain") + } + if hasattr(sdr, "init_tx") and all(v is not None for v in init_args.values()): + sdr.init_tx( + sample_rate=init_args["sample_rate"], + center_frequency=init_args["center_frequency"], + gain=init_args["gain"], + channel=radio_config.get("tx_channel", 0), + gain_mode=radio_config.get("tx_gain_mode", "manual"), + ) + except Exception as exc: + if device_key is not None: + if self._registry.release(device_key): + try: + sdr.close() + except Exception: + pass + logger.exception("Failed to init TX on %r", device) + await self._send_tx_status(app_id, "error", f"tx init failed: {exc}") + return + + self._loop = asyncio.get_running_loop() + session = TxSession( + app_id=app_id, + sdr=sdr, + device_key=device_key, + buffer_size=buffer_size, + underrun_policy=underrun_policy, + started_at=time.monotonic(), + max_duration_s=self._cfg.tx_max_duration_s, + ) + self._tx = session + await self._send_tx_status(app_id, "armed") + session.task = self._loop.run_in_executor(None, self._tx_executor_body, session) + # Spawn a small watchdog that transitions armed → transmitting when + # the first buffer has been consumed, and surfaces underrun / max- + # duration terminations back to the hub. + asyncio.create_task(self._tx_watchdog(session)) + + async def _handle_tx_stop(self, msg: dict) -> None: + session = self._tx + if session is None: + return + app_id = session.app_id + session.stop_event.set() + try: + session.sdr.pause_tx() + except Exception: + logger.debug("pause_tx raised during stop", exc_info=True) + # Wake the executor thread if it's blocked on ``queue.get``. + self._drain_tx_queue(session) + if session.task is not None: + try: + await asyncio.wait_for(asyncio.wrap_future(session.task), timeout=1.5) + except asyncio.TimeoutError: + logger.warning("TX executor did not exit within 1.5s after stop") + except Exception: + logger.debug("TX executor raised on shutdown", exc_info=True) + self._close_session_sdr(session) + self._tx = None + await self._send_tx_status(app_id, "done") + + async def _handle_tx_configure(self, msg: dict) -> None: + if self._tx is None: + return + self._tx.pending_config.update(msg.get("radio_config") or {}) + + # ------------------------------------------------------------------ + # TX executor & watchdog + + def _tx_executor_body(self, session: TxSession) -> None: + try: + session.sdr._stream_tx(lambda n: self._tx_callback(session, n)) + except Exception: + logger.exception("TX stream crashed") + self._schedule(self._send_tx_status(session.app_id, "error", "tx stream crashed")) + + def _tx_callback(self, session: TxSession, num_samples) -> np.ndarray: + n = int(num_samples) + # Honor stop requests: return silence one last time and let the driver + # exit its loop on the next iteration (pause_tx flips _enable_tx). + if session.stop_event.is_set(): + return _silence(n) + + # Max-duration watchdog. + if ( + session.max_duration_s is not None + and (time.monotonic() - session.started_at) >= float(session.max_duration_s) + ): + session.stop_event.set() + try: + session.sdr.pause_tx() + except Exception: + pass + self._schedule(self._send_tx_status(session.app_id, "done", "max duration reached")) + return _silence(n) + + # Apply queued configure at buffer boundary. + if session.pending_config: + cfg = session.pending_config + session.pending_config = {} + try: + _apply_sdr_config(session.sdr, cfg) + except Exception as exc: + logger.debug("tx_configure apply failed: %s", exc) + + try: + raw = session.in_queue.get(timeout=0.1) + except queue.Empty: + return self._underrun_fill(session, n) + + arr = np.frombuffer(raw, dtype=np.float32) + if arr.size < 2 or arr.size % 2 != 0: + logger.warning("Malformed TX frame: %d floats (must be non-zero even count)", arr.size) + return self._underrun_fill(session, n) + samples = (arr[0::2].astype(np.complex64) + 1j * arr[1::2].astype(np.complex64)) + if samples.size < n: + out = np.zeros(n, dtype=np.complex64) + out[: samples.size] = samples + session.last_buffer = out + return out + if samples.size > n: + samples = samples[:n] + session.last_buffer = samples + if session.state == "armed": + session.state = "transmitting" + self._schedule(self._send_tx_status(session.app_id, "transmitting")) + return samples + + def _underrun_fill(self, session: TxSession, n: int) -> np.ndarray: + policy = session.underrun_policy + if policy == "zero": + return _silence(n) + if policy == "repeat" and session.last_buffer is not None: + buf = session.last_buffer + if buf.size == n: + return buf + if buf.size > n: + return buf[:n].copy() + out = np.zeros(n, dtype=np.complex64) + out[: buf.size] = buf + return out + # "pause" policy (default) or "repeat" before any buffer arrived. + if not session.underrun_flag.is_set(): + session.underrun_flag.set() + session.stop_event.set() + try: + session.sdr.pause_tx() except Exception: pass - self._sdr = None + return _silence(n) - async def _send_status(self, status: str) -> None: + async def _tx_watchdog(self, session: TxSession) -> None: + # Poll the underrun flag so we can emit status + tear down cleanly + # when the callback flips the flag from the executor thread. Check + # underrun_flag before stop_event, since the "pause" path sets both. + while session is self._tx: + if session.underrun_flag.is_set(): + await self._send_tx_status(session.app_id, "underrun") + await self._teardown_tx_after_underrun(session) + return + if session.stop_event.is_set(): + return + await asyncio.sleep(0.05) + + async def _teardown_tx_after_underrun(self, session: TxSession) -> None: + if self._tx is not session: + return + self._drain_tx_queue(session) + if session.task is not None: + try: + await asyncio.wait_for(asyncio.wrap_future(session.task), timeout=1.0) + except asyncio.TimeoutError: + logger.warning("TX executor did not exit within 1s after underrun") + except Exception: + logger.debug("TX executor raised during underrun teardown", exc_info=True) + self._close_session_sdr(session) + if self._tx is session: + self._tx = None + + def _drain_tx_queue(self, session: TxSession) -> None: try: - await self.ws.send_json({"type": "status", "status": status, "app_id": self._app_id}) + while True: + session.in_queue.get_nowait() + except queue.Empty: + pass + + def _schedule(self, coro) -> None: + loop = self._loop + if loop is None: + return + try: + asyncio.run_coroutine_threadsafe(coro, loop) + except Exception: + logger.debug("_schedule failed", exc_info=True) + + # ================================================================== + # Helpers + + def _close_session_sdr(self, session) -> None: + if session.sdr is None: + return + should_close = self._registry.release(session.device_key) + if should_close: + try: + session.sdr.close() + except Exception: + logger.debug("SDR close raised", exc_info=True) + + async def _send_status(self, status: str, app_id: str) -> None: + try: + await self.ws.send_json({"type": "status", "status": status, "app_id": app_id}) except Exception as exc: logger.debug("Status send failed: %s", exc) - async def _send_error(self, message: str) -> None: + async def _send_error(self, app_id: str, message: str) -> None: try: - await self.ws.send_json({"type": "error", "app_id": self._app_id, "message": message}) + await self.ws.send_json({"type": "error", "app_id": app_id, "message": message}) except Exception as exc: logger.debug("Error-frame send failed: %s", exc) + async def _send_tx_status(self, app_id: str, state: str, message: str | None = None) -> None: + payload: dict = {"type": "tx_status", "app_id": app_id, "state": state} + if message is not None: + payload["message"] = message + try: + await self.ws.send_json(payload) + except Exception as exc: + logger.debug("tx_status send failed: %s", exc) + # --------------------------------------------------------------------------- # Helpers @@ -172,6 +636,10 @@ _CONFIG_ATTR_MAP = { "center_freq": ("center_freq", "rx_center_frequency"), "gain": ("gain", "rx_gain"), "bandwidth": ("bandwidth", "rx_bandwidth"), + "tx_sample_rate": ("tx_sample_rate",), + "tx_center_frequency": ("tx_center_frequency", "tx_lo"), + "tx_gain": ("tx_gain",), + "tx_bandwidth": ("tx_bandwidth",), } @@ -194,6 +662,11 @@ def _apply_sdr_config(sdr: Any, cfg: dict) -> None: logger.debug("radio_config key %r ignored (no matching attr)", key) +def _silence(num_samples: int) -> np.ndarray: + """Return a ``num_samples``-length zero-filled complex64 buffer.""" + return np.zeros(int(num_samples), dtype=np.complex64) + + def _samples_to_interleaved_float32(samples: Any) -> bytes: """Convert complex IQ samples (any numeric dtype) to interleaved float32 bytes.""" arr = np.asarray(samples) @@ -214,8 +687,12 @@ def _default_sdr_factory(device: str, identifier: str | None): # --------------------------------------------------------------------------- # Top-level entry -async def run_streamer(ws_url: str, token: str) -> None: +async def run_streamer(ws_url: str, token: str, *, cfg: AgentConfig | None = None) -> None: """Connect to *ws_url* and run the streamer loop until cancelled.""" ws = WsClient(ws_url, token) - streamer = Streamer(ws) - await ws.run(streamer.on_message, streamer.build_heartbeat) + streamer = Streamer(ws, cfg=cfg) + await ws.run( + streamer.on_message, + streamer.build_heartbeat, + on_binary=streamer.on_binary, + ) diff --git a/src/ria_toolkit_oss/agent/ws_client.py b/src/ria_toolkit_oss/agent/ws_client.py index 1bc66f6..a33991d 100644 --- a/src/ria_toolkit_oss/agent/ws_client.py +++ b/src/ria_toolkit_oss/agent/ws_client.py @@ -15,6 +15,7 @@ logger = logging.getLogger("ria_agent.ws") MessageHandler = Callable[[dict], Awaitable[None]] HeartbeatBuilder = Callable[[], dict] +BinaryHandler = Callable[[bytes], Awaitable[None]] class WsClient: @@ -65,7 +66,12 @@ class WsClient: self._stop.set() # ------------------------------------------------------------------ - async def run(self, on_message: MessageHandler, heartbeat: HeartbeatBuilder) -> None: + async def run( + self, + on_message: MessageHandler, + heartbeat: HeartbeatBuilder, + on_binary: BinaryHandler | None = None, + ) -> None: """Main loop: connect, heartbeat, dispatch messages, reconnect on drop.""" while not self._stop.is_set(): try: @@ -75,8 +81,13 @@ class WsClient: try: async for raw in self._ws: if isinstance(raw, bytes): - # Server shouldn't send binary to the agent; log and drop. - logger.debug("Discarding unexpected %d-byte binary frame", len(raw)) + if on_binary is None: + logger.debug("Discarding unexpected %d-byte binary frame", len(raw)) + continue + try: + await on_binary(raw) + except Exception: + logger.exception("on_binary handler raised; dropping frame") continue try: msg = json.loads(raw) diff --git a/src/ria_toolkit_oss/app/cli.py b/src/ria_toolkit_oss/app/cli.py index 6cd0c1c..9bfb479 100644 --- a/src/ria_toolkit_oss/app/cli.py +++ b/src/ria_toolkit_oss/app/cli.py @@ -21,6 +21,7 @@ from __future__ import annotations import argparse import json +import os import shutil import subprocess import sys @@ -77,24 +78,33 @@ def _inspect_labels(engine: list[str], ref: str) -> dict: return {} -def _hardware_flags(labels: dict) -> list[str]: +def _gpu_available() -> bool: + if os.path.exists("/dev/nvidia0"): + return True + return shutil.which("nvidia-smi") is not None + + +def _hardware_flags(labels: dict, no_gpu: bool, no_usb: bool, no_host_net: bool) -> tuple[list[str], list[str]]: flags: list[str] = [] + notes: list[str] = [] profile = (labels.get(_LABEL_PROFILE) or "").lower() hardware = (labels.get(_LABEL_HARDWARE) or "").lower() hw_items = {h.strip() for h in hardware.split(",") if h.strip()} - if "nvidia" in profile or "holoscan" in profile or "cuda" in profile: - flags += ["--gpus", "all"] + wants_gpu = any(k in profile for k in ("nvidia", "holoscan", "cuda")) + if wants_gpu and not no_gpu: + if _gpu_available(): + flags += ["--gpus", "all"] + else: + notes.append("image wants GPU but no NVIDIA runtime detected — skipping --gpus (use --force-gpu to override)") - needs_usb = hw_items & {"pluto", "rtlsdr", "hackrf", "bladerf"} - if needs_usb: + if hw_items & {"pluto", "rtlsdr", "hackrf", "bladerf"} and not no_usb: flags += ["--device", "/dev/bus/usb"] - needs_net = hw_items & {"usrp", "thinkrf", "pluto"} - if needs_net: + if hw_items & {"usrp", "thinkrf", "pluto"} and not no_host_net: flags += ["--net", "host"] - return flags + return flags, notes def _cmd_configure(args: argparse.Namespace) -> int: @@ -132,7 +142,10 @@ def _cmd_run(args: argparse.Namespace) -> int: return rc labels = _inspect_labels(engine, ref) - hw_flags = _hardware_flags(labels) + no_gpu = args.no_gpu and not args.force_gpu + hw_flags, notes = _hardware_flags(labels, no_gpu=no_gpu, no_usb=args.no_usb, no_host_net=args.no_host_net) + if args.force_gpu and "--gpus" not in hw_flags: + hw_flags = ["--gpus", "all", *hw_flags] cmd = [*engine, "run", "--rm"] if not args.foreground: @@ -162,6 +175,8 @@ def _cmd_run(args: argparse.Namespace) -> int: print(f"Running {ref} [{label_str}]") if hw_flags: print(f" auto flags: {' '.join(hw_flags)}") + for note in notes: + print(f" note: {note}") return subprocess.call(cmd) @@ -225,6 +240,10 @@ def main() -> None: p_run.add_argument("-v", "--volume", action="append", help="Extra volume mount") p_run.add_argument("-p", "--publish", action="append", help="Publish port") p_run.add_argument("--foreground", "-F", action="store_true", help="Run in foreground (no -d)") + p_run.add_argument("--no-gpu", action="store_true", help="Skip --gpus flag even if image wants GPU") + p_run.add_argument("--force-gpu", action="store_true", help="Force --gpus all even if no NVIDIA runtime detected") + p_run.add_argument("--no-usb", action="store_true", help="Skip --device /dev/bus/usb") + p_run.add_argument("--no-host-net", action="store_true", help="Skip --net host") p_run.add_argument("--dry-run", action="store_true", help="Print the container command and exit") p_run.add_argument("--docker-args", nargs=argparse.REMAINDER, help="Pass remaining args to docker/podman run") p_run.add_argument("--app-args", nargs=argparse.REMAINDER, help="Pass remaining args to the app entrypoint") diff --git a/tests/agent/test_cli_tx.py b/tests/agent/test_cli_tx.py new file mode 100644 index 0000000..1543d4c --- /dev/null +++ b/tests/agent/test_cli_tx.py @@ -0,0 +1,111 @@ +"""CLI flags for TX opt-in and interlocks.""" + +from __future__ import annotations + +import json +import sys +from unittest.mock import patch + +from ria_toolkit_oss.agent import cli as agent_cli +from ria_toolkit_oss.agent import config as agent_config + + +class _FakeResp: + def __init__(self, payload: dict): + self._payload = payload + + def read(self) -> bytes: + return json.dumps(self._payload).encode() + + def __enter__(self): + return self + + def __exit__(self, *_a): + return False + + +def _run_register(argv: list[str], cfg_path) -> int: + fake_resp = _FakeResp({"agent_id": "agent-1", "token": "tok-abc"}) + with patch.dict("os.environ", {"RIA_AGENT_CONFIG": str(cfg_path)}, clear=False), \ + patch("urllib.request.urlopen", return_value=fake_resp), \ + patch.object(sys, "argv", ["ria-agent", *argv]): + try: + agent_cli.main() + except SystemExit as exc: + return int(exc.code or 0) + return 0 + + +def test_register_without_allow_tx_keeps_tx_disabled(tmp_path): + cfg_path = tmp_path / "agent.json" + _run_register( + ["register", "--hub", "http://hub:3005", "--api-key", "K"], + cfg_path, + ) + cfg = agent_config.load(path=cfg_path) + assert cfg.agent_id == "agent-1" + assert cfg.tx_enabled is False + assert cfg.tx_max_gain_db is None + + +def test_register_with_allow_tx_and_caps(tmp_path): + cfg_path = tmp_path / "agent.json" + _run_register( + [ + "register", + "--hub", + "http://hub:3005", + "--api-key", + "K", + "--allow-tx", + "--tx-max-gain-db", + "-10", + "--tx-max-duration-s", + "60", + "--tx-freq-range", + "2.4e9", + "2.5e9", + "--tx-freq-range", + "5.7e9", + "5.8e9", + ], + cfg_path, + ) + cfg = agent_config.load(path=cfg_path) + assert cfg.tx_enabled is True + assert cfg.tx_max_gain_db == -10.0 + assert cfg.tx_max_duration_s == 60.0 + assert cfg.tx_allowed_freq_ranges == [[2.4e9, 2.5e9], [5.7e9, 5.8e9]] + + +def test_stream_allow_tx_does_not_persist(tmp_path): + # Pre-register with tx_enabled=False, then simulate `stream --allow-tx`. + # The on-disk config must remain unchanged; the runtime flag is process-local. + cfg_path = tmp_path / "agent.json" + base = agent_config.AgentConfig( + hub_url="http://hub:3005", + agent_id="agent-1", + token="tok-abc", + tx_enabled=False, + ) + agent_config.save(base, path=cfg_path) + + captured: dict = {} + + async def _fake_run_streamer(url, token, *, cfg): + captured["cfg"] = cfg + return None + + with patch.dict("os.environ", {"RIA_AGENT_CONFIG": str(cfg_path)}, clear=False), \ + patch("ria_toolkit_oss.agent.streamer.run_streamer", new=_fake_run_streamer), \ + patch.object(sys, "argv", ["ria-agent", "stream", "--allow-tx"]): + try: + agent_cli.main() + except SystemExit: + pass + + # Runtime cfg had TX flipped on + assert captured["cfg"].tx_enabled is True + # But the persisted file is untouched + on_disk = agent_config.load(path=cfg_path) + assert on_disk.tx_enabled is False diff --git a/tests/agent/test_config.py b/tests/agent/test_config.py index 2532abd..7d2a6b4 100644 --- a/tests/agent/test_config.py +++ b/tests/agent/test_config.py @@ -20,6 +20,36 @@ def test_load_missing_returns_empty(tmp_path): assert loaded == agent_config.AgentConfig() +def test_tx_fields_round_trip(tmp_path): + p = tmp_path / "agent.json" + cfg = agent_config.AgentConfig( + hub_url="https://hub.example.com", + agent_id="agent-1", + token="t", + tx_enabled=True, + tx_max_gain_db=-10.0, + tx_max_duration_s=60.0, + tx_allowed_freq_ranges=[[2.4e9, 2.5e9], [5.7e9, 5.8e9]], + ) + agent_config.save(cfg, path=p) + loaded = agent_config.load(path=p) + assert loaded.tx_enabled is True + assert loaded.tx_max_gain_db == -10.0 + assert loaded.tx_max_duration_s == 60.0 + assert loaded.tx_allowed_freq_ranges == [[2.4e9, 2.5e9], [5.7e9, 5.8e9]] + + +def test_tx_fields_default_when_absent(tmp_path): + # Old configs written before TX existed should load cleanly with safe defaults. + p = tmp_path / "agent.json" + p.write_text('{"hub_url": "x", "agent_id": "a", "token": "t"}') + cfg = agent_config.load(path=p) + assert cfg.tx_enabled is False + assert cfg.tx_max_gain_db is None + assert cfg.tx_max_duration_s is None + assert cfg.tx_allowed_freq_ranges is None + + def test_extra_keys_preserved(tmp_path): p = tmp_path / "agent.json" p.write_text('{"hub_url": "x", "custom": 42}') diff --git a/tests/agent/test_disconnect.py b/tests/agent/test_disconnect.py index f063e3a..3063613 100644 --- a/tests/agent/test_disconnect.py +++ b/tests/agent/test_disconnect.py @@ -67,9 +67,9 @@ def test_streamer_reports_disconnected_and_ends_capture(): "radio_config": {"device": "fake", "buffer_size": 8}, } ) - # Wait for the capture task to fail out. - for _ in range(50): - if streamer._capture_task and streamer._capture_task.done(): + # Wait for the capture loop to emit its error frame and tear down the session. + for _ in range(100): + if any(m.get("type") == "error" for m in ws.json_sent) and streamer._rx is None: break await asyncio.sleep(0.01) return ws, sdr, streamer @@ -79,3 +79,5 @@ def test_streamer_reports_disconnected_and_ends_capture(): errors = [m for m in ws.json_sent if m.get("type") == "error"] assert errors, "expected an error frame" assert "disconnected" in errors[-1]["message"].lower() + # Session handle cleared so future starts can proceed. + assert streamer._rx is None diff --git a/tests/agent/test_full_duplex.py b/tests/agent/test_full_duplex.py new file mode 100644 index 0000000..6ad2f62 --- /dev/null +++ b/tests/agent/test_full_duplex.py @@ -0,0 +1,133 @@ +"""Concurrent RX + TX sessions on the same agent — shared SDR via registry.""" + +from __future__ import annotations + +import asyncio +import time + +import numpy as np + +from ria_toolkit_oss.agent.config import AgentConfig +from ria_toolkit_oss.agent.streamer import Streamer +from ria_toolkit_oss.sdr.mock import MockSDR + + +class FullDuplexMockSDR(MockSDR): + """MockSDR with a recording TX path so the test can assert both directions.""" + + def __init__(self, buffer_size: int): + super().__init__(buffer_size=buffer_size) + self.tx_produced: list[np.ndarray] = [] + + def _stream_tx(self, callback): + self._enable_tx = True + self._tx_initialized = True + while self._enable_tx: + result = callback(self.rx_buffer_size) + self.tx_produced.append(np.asarray(result).copy()) + time.sleep(0.005) + + +class FakeWs: + def __init__(self): + self.json_sent = [] + self.bytes_sent = [] + + async def send_json(self, p): + self.json_sent.append(p) + + async def send_bytes(self, b): + self.bytes_sent.append(b) + + +def _iq_frame(samples: np.ndarray) -> bytes: + interleaved = np.empty(samples.size * 2, dtype=np.float32) + interleaved[0::2] = samples.real + interleaved[1::2] = samples.imag + return interleaved.tobytes() + + +def test_rx_and_tx_share_one_sdr_instance(): + built: list[FullDuplexMockSDR] = [] + + def factory(device, identifier): + sdr = FullDuplexMockSDR(buffer_size=16) + built.append(sdr) + return sdr + + async def scenario(): + ws = FakeWs() + s = Streamer(ws=ws, sdr_factory=factory, cfg=AgentConfig(tx_enabled=True)) + + # Start RX first. + await s.on_message( + { + "type": "start", + "app_id": "app-1", + "radio_config": {"device": "mock", "buffer_size": 16}, + } + ) + # Then start TX on the same device — should share the SDR handle. + await s.on_message( + { + "type": "tx_start", + "app_id": "app-1", + "radio_config": { + "device": "mock", + "buffer_size": 16, + "tx_gain": -20, + "tx_center_frequency": 2.45e9, + "underrun_policy": "zero", + }, + } + ) + + # Push a known TX buffer. + marker = np.arange(16, dtype=np.complex64) + 7 + await s.on_binary(_iq_frame(marker)) + + # Let both directions produce output. + for _ in range(80): + rx_ok = len(ws.bytes_sent) >= 2 + tx_ok = any(np.array_equal(b, marker) for b in built[0].tx_produced) if built else False + if rx_ok and tx_ok: + break + await asyncio.sleep(0.01) + + # Heartbeat should show both sessions. + hb = s.build_heartbeat() + + # Stop TX first, RX keeps running. + await s.on_message({"type": "tx_stop", "app_id": "app-1"}) + tx_after_stop = s._tx is None + rx_still_active = s._rx is not None + + # Now stop RX. + await s.on_message({"type": "stop", "app_id": "app-1"}) + + return ws, s, built, hb, tx_after_stop, rx_still_active + + ws, s, built, hb, tx_after_stop, rx_still_active = asyncio.run(scenario()) + + # One SDR was built and shared. + assert len(built) == 1, f"expected exactly one SDR instance, got {len(built)}" + + # Both directions produced output. + assert len(ws.bytes_sent) >= 1, "RX produced no IQ frames" + marker = np.arange(16, dtype=np.complex64) + 7 + assert any( + np.array_equal(b, marker) for b in built[0].tx_produced + ), "TX callback never saw the pushed marker buffer" + + # Heartbeat reflected both sessions while they were active. + assert hb["sessions"]["rx"]["app_id"] == "app-1" + assert hb["sessions"]["tx"]["app_id"] == "app-1" + + # Stopping TX does not tear down RX. + assert tx_after_stop + assert rx_still_active + + # After both stops, registry is empty. + assert s._registry.refcount(("mock", None)) == 0 + assert s._rx is None + assert s._tx is None diff --git a/tests/agent/test_hardware.py b/tests/agent/test_hardware.py index ab9fcdf..51b2e45 100644 --- a/tests/agent/test_hardware.py +++ b/tests/agent/test_hardware.py @@ -23,7 +23,24 @@ def test_heartbeat_payload_shape(): assert p["status"] == "idle" assert "mock" in p["hardware"] assert "app_id" not in p + # New fields, default shape + assert p["capabilities"] == ["rx"] + assert p["tx_enabled"] is False p2 = hardware.heartbeat_payload(status="streaming", app_id="abc") assert p2["status"] == "streaming" assert p2["app_id"] == "abc" + + +def test_heartbeat_payload_tx_capability_from_cfg(): + from ria_toolkit_oss.agent.config import AgentConfig + + p = hardware.heartbeat_payload(cfg=AgentConfig(tx_enabled=True)) + assert p["capabilities"] == ["rx", "tx"] + assert p["tx_enabled"] is True + + +def test_heartbeat_payload_sessions_field(): + sessions = {"rx": {"app_id": "a", "state": "streaming"}} + p = hardware.heartbeat_payload(status="streaming", app_id="a", sessions=sessions) + assert p["sessions"] == sessions diff --git a/tests/agent/test_integration_tx.py b/tests/agent/test_integration_tx.py new file mode 100644 index 0000000..4fc13af --- /dev/null +++ b/tests/agent/test_integration_tx.py @@ -0,0 +1,144 @@ +"""End-to-end: local websockets server drives a Streamer's TX path.""" + +from __future__ import annotations + +import asyncio +import json +import time + +import numpy as np +import websockets + +from ria_toolkit_oss.agent.config import AgentConfig +from ria_toolkit_oss.agent.streamer import Streamer +from ria_toolkit_oss.agent.ws_client import WsClient +from ria_toolkit_oss.sdr.mock import MockSDR + + +class RecordingMockSDR(MockSDR): + def __init__(self, buffer_size: int): + super().__init__(buffer_size=buffer_size) + self.tx_produced: list[np.ndarray] = [] + + def _stream_tx(self, callback): + self._enable_tx = True + self._tx_initialized = True + while self._enable_tx: + result = callback(self.rx_buffer_size) + self.tx_produced.append(np.asarray(result).copy()) + time.sleep(0.005) + + +def _iq_frame(samples: np.ndarray) -> bytes: + interleaved = np.empty(samples.size * 2, dtype=np.float32) + interleaved[0::2] = samples.real + interleaved[1::2] = samples.imag + return interleaved.tobytes() + + +def test_server_tx_start_binary_stop_cycle_over_real_ws(): + BUF = 16 + sdr = RecordingMockSDR(buffer_size=BUF) + marker = np.arange(BUF, dtype=np.complex64) + 1 + + async def scenario(): + control_frames: list[dict] = [] + done = asyncio.Event() + + async def server_handler(ws): + try: + # Drain initial heartbeat. + first = await asyncio.wait_for(ws.recv(), timeout=2.0) + control_frames.append(json.loads(first)) + + await ws.send( + json.dumps( + { + "type": "tx_start", + "app_id": "tx-app", + "radio_config": { + "device": "mock", + "buffer_size": BUF, + "tx_sample_rate": 1_000_000, + "tx_center_frequency": 2.45e9, + "tx_gain": -20, + "underrun_policy": "zero", + }, + } + ) + ) + + # Push a few binary IQ frames. + for _ in range(3): + await ws.send(_iq_frame(marker)) + + # Wait for at least "armed" + "transmitting" statuses. + for _ in range(100): + msg = await asyncio.wait_for(ws.recv(), timeout=2.0) + if isinstance(msg, str): + control_frames.append(json.loads(msg)) + if any( + f.get("type") == "tx_status" and f.get("state") == "transmitting" + for f in control_frames + ): + break + + await ws.send(json.dumps({"type": "tx_stop", "app_id": "tx-app"})) + + # Drain trailing statuses. + try: + while True: + msg = await asyncio.wait_for(ws.recv(), timeout=0.5) + if isinstance(msg, str): + control_frames.append(json.loads(msg)) + except (asyncio.TimeoutError, Exception): + pass + finally: + done.set() + + server = await websockets.serve(server_handler, "127.0.0.1", 0) + port = server.sockets[0].getsockname()[1] + try: + client = WsClient( + f"ws://127.0.0.1:{port}", + token="", + heartbeat_interval=10.0, + reconnect_pause=0.05, + ) + streamer = Streamer( + ws=client, + sdr_factory=lambda d, i: sdr, + cfg=AgentConfig(tx_enabled=True), + ) + task = asyncio.create_task( + client.run( + on_message=streamer.on_message, + heartbeat=streamer.build_heartbeat, + on_binary=streamer.on_binary, + ) + ) + await asyncio.wait_for(done.wait(), timeout=5.0) + client.stop() + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass + finally: + server.close() + await server.wait_closed() + return control_frames, streamer + + controls, streamer = asyncio.run(scenario()) + + # Heartbeat reached the server. + assert any(f.get("type") == "heartbeat" for f in controls) + # tx_status lifecycle: armed → transmitting → done. + tx_states = [f["state"] for f in controls if f.get("type") == "tx_status"] + assert tx_states[0] == "armed" + assert "transmitting" in tx_states + assert tx_states[-1] == "done" + # TX callback saw our marker buffer at least once. + assert any(np.array_equal(b, marker) for b in sdr.tx_produced) + # Session cleared. + assert streamer._tx is None diff --git a/tests/agent/test_streamer.py b/tests/agent/test_streamer.py index 1bb2081..2aa842e 100644 --- a/tests/agent/test_streamer.py +++ b/tests/agent/test_streamer.py @@ -46,15 +46,29 @@ def test_apply_sdr_config_sets_attributes(): def test_heartbeat_reflects_status_and_app(): - s = Streamer(ws=FakeWs(), sdr_factory=_factory) - hb = s.build_heartbeat() - assert hb["type"] == "heartbeat" - assert hb["status"] == "idle" - s._status = "streaming" - s._app_id = "app-42" - hb2 = s.build_heartbeat() - assert hb2["status"] == "streaming" - assert hb2["app_id"] == "app-42" + async def scenario(): + s = Streamer(ws=FakeWs(), sdr_factory=_factory) + hb = s.build_heartbeat() + assert hb["type"] == "heartbeat" + assert hb["status"] == "idle" + # capabilities default to rx-only + assert hb["capabilities"] == ["rx"] + assert hb["tx_enabled"] is False + + await s.on_message( + { + "type": "start", + "app_id": "app-42", + "radio_config": {"device": "mock", "buffer_size": 32}, + } + ) + hb2 = s.build_heartbeat() + assert hb2["status"] == "streaming" + assert hb2["app_id"] == "app-42" + assert hb2["sessions"]["rx"]["app_id"] == "app-42" + await s.on_message({"type": "stop", "app_id": "app-42"}) + + asyncio.run(scenario()) def test_full_start_stream_stop_cycle(): @@ -89,7 +103,7 @@ def test_full_start_stream_stop_cycle(): statuses = [m for m in ws.json_sent if m.get("type") == "status"] assert statuses[0]["status"] == "streaming" assert statuses[-1]["status"] == "idle" - assert streamer._sdr is None + assert streamer._rx is None def test_start_without_device_emits_error(): @@ -110,6 +124,7 @@ def test_configure_queues_update(): await streamer.on_message( {"type": "configure", "app_id": "x", "radio_config": {"center_frequency": 915e6}} ) + # Before start(), pending config lives on the standalone dict exposed via the _pending_config shim. return streamer._pending_config pending = asyncio.run(scenario()) @@ -122,3 +137,56 @@ def test_unknown_message_type_is_ignored(): await s.on_message({"type": "nope"}) asyncio.run(scenario()) + + +def test_registry_shares_sdr_across_start_stop_cycles(): + # Two sequential start/stop cycles with the same (device, identifier) + # should hit the registry's cache path rather than constructing a new SDR. + built: list[MockSDR] = [] + + def counting_factory(device: str, identifier): + sdr = MockSDR(buffer_size=16, seed=0) + built.append(sdr) + return sdr + + async def scenario(): + s = Streamer(ws=FakeWs(), sdr_factory=counting_factory) + for _ in range(2): + await s.on_message( + { + "type": "start", + "app_id": "a", + "radio_config": {"device": "mock", "buffer_size": 16}, + } + ) + # Let one capture buffer flow before stopping so the loop is engaged. + await asyncio.sleep(0.02) + await s.on_message({"type": "stop", "app_id": "a"}) + + asyncio.run(scenario()) + # A new SDR per cycle (we fully close between starts) — registry refcount + # drops to zero on each stop. This test confirms close-and-rebuild works; + # the ref-counting share-while-open case is covered in the full-duplex tests. + assert len(built) == 2 + + +def test_tx_start_rejected_when_tx_disabled(): + from ria_toolkit_oss.agent.config import AgentConfig + + async def scenario(): + ws = FakeWs() + s = Streamer(ws=ws, sdr_factory=_factory, cfg=AgentConfig(tx_enabled=False)) + await s.on_message( + { + "type": "tx_start", + "app_id": "a", + "radio_config": {"device": "mock", "tx_center_frequency": 2.45e9, "tx_gain": -20}, + } + ) + return ws + + ws = asyncio.run(scenario()) + tx_statuses = [m for m in ws.json_sent if m.get("type") == "tx_status"] + assert tx_statuses, "expected a tx_status frame" + assert tx_statuses[-1]["state"] == "error" + assert "disabled" in tx_statuses[-1]["message"].lower() diff --git a/tests/agent/test_streamer_tx.py b/tests/agent/test_streamer_tx.py new file mode 100644 index 0000000..6cb2bb4 --- /dev/null +++ b/tests/agent/test_streamer_tx.py @@ -0,0 +1,133 @@ +"""TX streaming happy path + shutdown semantics.""" + +from __future__ import annotations + +import asyncio +import time + +import numpy as np + +from ria_toolkit_oss.agent.config import AgentConfig +from ria_toolkit_oss.agent.streamer import Streamer +from ria_toolkit_oss.sdr.mock import MockSDR + + +class RecordingMockSDR(MockSDR): + """MockSDR that records each TX callback's returned buffer.""" + + def __init__(self, buffer_size: int): + super().__init__(buffer_size=buffer_size) + self.tx_produced: list[np.ndarray] = [] + + def _stream_tx(self, callback) -> None: + self._enable_tx = True + self._tx_initialized = True + while self._enable_tx: + result = callback(self.rx_buffer_size) + self.tx_produced.append(np.asarray(result)) + time.sleep(0.005) + + +class FakeWs: + def __init__(self): + self.json_sent: list[dict] = [] + self.bytes_sent: list[bytes] = [] + + async def send_json(self, payload): + self.json_sent.append(payload) + + async def send_bytes(self, data): + self.bytes_sent.append(data) + + +def _iq_frame(samples: np.ndarray) -> bytes: + interleaved = np.empty(samples.size * 2, dtype=np.float32) + interleaved[0::2] = samples.real + interleaved[1::2] = samples.imag + return interleaved.tobytes() + + +def test_tx_start_streams_binary_to_callback(): + BUF = 16 + sdr = RecordingMockSDR(buffer_size=BUF) + + async def scenario(): + ws = FakeWs() + s = Streamer(ws=ws, sdr_factory=lambda d, i: sdr, cfg=AgentConfig(tx_enabled=True)) + + # Frames of distinct content so we can assert ordering. + frame_a = np.arange(BUF, dtype=np.complex64) * (1 + 0j) + frame_b = (np.arange(BUF, dtype=np.complex64) + BUF) * (1 + 0j) + frame_c = (np.arange(BUF, dtype=np.complex64) + 2 * BUF) * (1 + 0j) + + await s.on_message( + { + "type": "tx_start", + "app_id": "app-1", + "radio_config": { + "device": "mock", + "buffer_size": BUF, + "tx_sample_rate": 1_000_000, + "tx_center_frequency": 2.45e9, + "tx_gain": -20, + "underrun_policy": "zero", + }, + } + ) + # Push three IQ frames. + await s.on_binary(_iq_frame(frame_a)) + await s.on_binary(_iq_frame(frame_b)) + await s.on_binary(_iq_frame(frame_c)) + + # Let the executor thread consume them. + for _ in range(100): + # At least the 3 real frames, plus any zero-fill from before they + # arrived. We stop once 3 non-trivial buffers are recorded. + nontrivial = [b for b in sdr.tx_produced if np.any(b != 0)] + if len(nontrivial) >= 3: + break + await asyncio.sleep(0.01) + + await s.on_message({"type": "tx_stop", "app_id": "app-1"}) + return ws, sdr, s + + ws, sdr, streamer = asyncio.run(scenario()) + + nontrivial = [b for b in sdr.tx_produced if np.any(b != 0)] + assert len(nontrivial) >= 3, "expected ≥3 nontrivial TX buffers" + + # First three nontrivial buffers match the order we pushed them. + np.testing.assert_array_equal(nontrivial[0], np.arange(BUF, dtype=np.complex64)) + np.testing.assert_array_equal(nontrivial[1], np.arange(BUF, 2 * BUF, dtype=np.complex64)) + np.testing.assert_array_equal(nontrivial[2], np.arange(2 * BUF, 3 * BUF, dtype=np.complex64)) + + # Lifecycle: armed → transmitting → done. + states = [m["state"] for m in ws.json_sent if m.get("type") == "tx_status"] + assert states[0] == "armed" + assert "transmitting" in states + assert states[-1] == "done" + # Session cleared. + assert streamer._tx is None + + +def test_tx_stop_releases_sdr(): + sdr = RecordingMockSDR(buffer_size=8) + + async def scenario(): + ws = FakeWs() + s = Streamer(ws=ws, sdr_factory=lambda d, i: sdr, cfg=AgentConfig(tx_enabled=True)) + await s.on_message( + { + "type": "tx_start", + "app_id": "a", + "radio_config": {"device": "mock", "buffer_size": 8, "underrun_policy": "zero"}, + } + ) + await asyncio.sleep(0.03) + await s.on_message({"type": "tx_stop", "app_id": "a"}) + return s + + s = asyncio.run(scenario()) + # After stop, the registry has no outstanding references to ("mock", None). + assert s._registry.refcount(("mock", None)) == 0 + assert s._tx is None diff --git a/tests/agent/test_tx_safety.py b/tests/agent/test_tx_safety.py new file mode 100644 index 0000000..5307917 --- /dev/null +++ b/tests/agent/test_tx_safety.py @@ -0,0 +1,167 @@ +"""Agent-side TX interlocks: gain cap, freq ranges, duplicate sessions, disabled.""" + +from __future__ import annotations + +import asyncio + +from ria_toolkit_oss.agent.config import AgentConfig +from ria_toolkit_oss.agent.streamer import Streamer +from ria_toolkit_oss.sdr.mock import MockSDR + + +class FakeWs: + def __init__(self): + self.json_sent = [] + self.bytes_sent = [] + + async def send_json(self, p): + self.json_sent.append(p) + + async def send_bytes(self, b): + self.bytes_sent.append(b) + + +def _last_tx_status(ws): + frames = [m for m in ws.json_sent if m.get("type") == "tx_status"] + return frames[-1] if frames else None + + +def _tx_start(app_id="a", **radio): + rc = {"device": "mock", "buffer_size": 16, "underrun_policy": "zero"} + rc.update(radio) + return {"type": "tx_start", "app_id": app_id, "radio_config": rc} + + +def _make_streamer(cfg): + built: list = [] + + def factory(device, identifier): + sdr = MockSDR(buffer_size=16) + built.append(sdr) + return sdr + + ws = FakeWs() + s = Streamer(ws=ws, sdr_factory=factory, cfg=cfg) + return s, ws, built + + +def test_rejects_when_tx_disabled(): + async def scenario(): + s, ws, built = _make_streamer(AgentConfig(tx_enabled=False)) + await s.on_message(_tx_start(tx_gain=-20, tx_center_frequency=2.45e9)) + return s, ws, built + + s, ws, built = asyncio.run(scenario()) + status = _last_tx_status(ws) + assert status and status["state"] == "error" + assert "disabled" in status["message"].lower() + assert not built, "SDR should never have been constructed" + assert s._tx is None + + +def test_rejects_when_tx_gain_exceeds_cap(): + async def scenario(): + s, ws, built = _make_streamer(AgentConfig(tx_enabled=True, tx_max_gain_db=-15.0)) + await s.on_message(_tx_start(tx_gain=-5, tx_center_frequency=2.45e9)) + return ws, built + + ws, built = asyncio.run(scenario()) + status = _last_tx_status(ws) + assert status and status["state"] == "error" + assert "exceeds cap" in status["message"] + assert not built + + +def test_allows_gain_at_cap_boundary(): + async def scenario(): + s, ws, _ = _make_streamer(AgentConfig(tx_enabled=True, tx_max_gain_db=-10.0)) + await s.on_message(_tx_start(tx_gain=-10, tx_center_frequency=2.45e9)) + # Stop promptly to avoid keeping an executor thread around. + await asyncio.sleep(0.02) + await s.on_message({"type": "tx_stop", "app_id": "a"}) + return ws + + ws = asyncio.run(scenario()) + states = [m["state"] for m in ws.json_sent if m.get("type") == "tx_status"] + assert "armed" in states + assert states[-1] == "done" + + +def test_rejects_when_freq_outside_ranges(): + async def scenario(): + s, ws, built = _make_streamer( + AgentConfig( + tx_enabled=True, + tx_allowed_freq_ranges=[[2.4e9, 2.5e9]], + ) + ) + await s.on_message(_tx_start(tx_center_frequency=5.8e9, tx_gain=-20)) + return ws, built + + ws, built = asyncio.run(scenario()) + status = _last_tx_status(ws) + assert status and status["state"] == "error" + assert "outside allowed ranges" in status["message"] + assert not built + + +def test_allows_freq_inside_a_range(): + async def scenario(): + s, ws, _ = _make_streamer( + AgentConfig( + tx_enabled=True, + tx_allowed_freq_ranges=[[2.4e9, 2.5e9], [5.7e9, 5.8e9]], + ) + ) + await s.on_message(_tx_start(tx_center_frequency=5.75e9, tx_gain=-20)) + await asyncio.sleep(0.02) + await s.on_message({"type": "tx_stop", "app_id": "a"}) + return ws + + ws = asyncio.run(scenario()) + states = [m["state"] for m in ws.json_sent if m.get("type") == "tx_status"] + assert "armed" in states + assert states[-1] == "done" + + +def test_rejects_duplicate_tx_session(): + async def scenario(): + s, ws, _ = _make_streamer(AgentConfig(tx_enabled=True)) + await s.on_message(_tx_start(app_id="a", tx_gain=-20, tx_center_frequency=2.45e9)) + await asyncio.sleep(0.01) + await s.on_message(_tx_start(app_id="b", tx_gain=-20, tx_center_frequency=2.45e9)) + # Let the second request process, then stop cleanly. + await asyncio.sleep(0.01) + await s.on_message({"type": "tx_stop", "app_id": "a"}) + return ws + + ws = asyncio.run(scenario()) + errors = [ + m for m in ws.json_sent + if m.get("type") == "tx_status" and m.get("state") == "error" + ] + assert any("already active" in e.get("message", "") for e in errors) + + +def test_rejects_invalid_underrun_policy(): + async def scenario(): + s, ws, _ = _make_streamer(AgentConfig(tx_enabled=True)) + await s.on_message( + { + "type": "tx_start", + "app_id": "a", + "radio_config": { + "device": "mock", + "buffer_size": 8, + "tx_gain": -20, + "tx_center_frequency": 2.45e9, + "underrun_policy": "teleport", + }, + } + ) + return ws + + ws = asyncio.run(scenario()) + status = _last_tx_status(ws) + assert status and status["state"] == "error" + assert "underrun_policy" in status["message"] diff --git a/tests/agent/test_tx_underrun.py b/tests/agent/test_tx_underrun.py new file mode 100644 index 0000000..e95feec --- /dev/null +++ b/tests/agent/test_tx_underrun.py @@ -0,0 +1,136 @@ +"""Underrun policies: pause, zero, repeat.""" + +from __future__ import annotations + +import asyncio +import time + +import numpy as np + +from ria_toolkit_oss.agent.config import AgentConfig +from ria_toolkit_oss.agent.streamer import Streamer +from ria_toolkit_oss.sdr.mock import MockSDR + + +class RecordingMockSDR(MockSDR): + def __init__(self, buffer_size: int): + super().__init__(buffer_size=buffer_size) + self.tx_produced: list[np.ndarray] = [] + + def _stream_tx(self, callback): + self._enable_tx = True + self._tx_initialized = True + while self._enable_tx: + result = callback(self.rx_buffer_size) + self.tx_produced.append(np.asarray(result).copy()) + time.sleep(0.005) + + +class FakeWs: + def __init__(self): + self.json_sent = [] + self.bytes_sent = [] + + async def send_json(self, p): + self.json_sent.append(p) + + async def send_bytes(self, b): + self.bytes_sent.append(b) + + +def _iq_frame(samples: np.ndarray) -> bytes: + interleaved = np.empty(samples.size * 2, dtype=np.float32) + interleaved[0::2] = samples.real + interleaved[1::2] = samples.imag + return interleaved.tobytes() + + +def _start_cfg(policy: str, buf: int = 8) -> dict: + return { + "type": "tx_start", + "app_id": "a", + "radio_config": { + "device": "mock", + "buffer_size": buf, + "tx_gain": -20, + "tx_center_frequency": 2.45e9, + "underrun_policy": policy, + }, + } + + +def test_underrun_pause_stops_session_and_emits_status(): + sdr = RecordingMockSDR(buffer_size=8) + + async def scenario(): + ws = FakeWs() + s = Streamer(ws=ws, sdr_factory=lambda d, i: sdr, cfg=AgentConfig(tx_enabled=True)) + await s.on_message(_start_cfg("pause")) + # Do not push any buffers. The callback underruns on first tick and + # the watchdog should emit "underrun" and tear down. + for _ in range(100): + if any( + m.get("type") == "tx_status" and m.get("state") == "underrun" + for m in ws.json_sent + ): + break + await asyncio.sleep(0.01) + for _ in range(50): + if s._tx is None: + break + await asyncio.sleep(0.01) + return ws, s + + ws, s = asyncio.run(scenario()) + states = [m["state"] for m in ws.json_sent if m.get("type") == "tx_status"] + assert "underrun" in states + assert s._tx is None + + +def test_underrun_zero_keeps_session_alive(): + sdr = RecordingMockSDR(buffer_size=8) + + async def scenario(): + ws = FakeWs() + s = Streamer(ws=ws, sdr_factory=lambda d, i: sdr, cfg=AgentConfig(tx_enabled=True)) + await s.on_message(_start_cfg("zero")) + # Let it produce several underrun-filled buffers. + await asyncio.sleep(0.08) + still_alive = s._tx is not None + await s.on_message({"type": "tx_stop", "app_id": "a"}) + return ws, still_alive + + ws, still_alive = asyncio.run(scenario()) + # No underrun status emitted (policy absorbs it silently). + assert not any( + m.get("type") == "tx_status" and m.get("state") == "underrun" for m in ws.json_sent + ) + assert still_alive + # All produced buffers are zero (no real data was pushed). + assert sdr.tx_produced, "expected at least one TX callback invocation" + assert all(not np.any(b != 0) for b in sdr.tx_produced) + + +def test_underrun_repeat_replays_last_buffer(): + BUF = 8 + sdr = RecordingMockSDR(buffer_size=BUF) + marker = np.arange(BUF, dtype=np.complex64) + 1 # distinct non-zero buffer + + async def scenario(): + ws = FakeWs() + s = Streamer(ws=ws, sdr_factory=lambda d, i: sdr, cfg=AgentConfig(tx_enabled=True)) + await s.on_message(_start_cfg("repeat", buf=BUF)) + await s.on_binary(_iq_frame(marker)) + # Give the executor time to consume the real frame + several repeats. + await asyncio.sleep(0.08) + await s.on_message({"type": "tx_stop", "app_id": "a"}) + return ws, sdr + + ws, sdr = asyncio.run(scenario()) + # No underrun status emitted. + assert not any( + m.get("type") == "tx_status" and m.get("state") == "underrun" for m in ws.json_sent + ) + # At least two buffers equal to the marker — the real one and ≥1 repeat. + matching = [b for b in sdr.tx_produced if np.array_equal(b, marker)] + assert len(matching) >= 2, f"expected ≥2 buffers matching marker, got {len(matching)}" diff --git a/tests/agent/test_ws_client.py b/tests/agent/test_ws_client.py index 0994a5b..4061f32 100644 --- a/tests/agent/test_ws_client.py +++ b/tests/agent/test_ws_client.py @@ -113,6 +113,109 @@ def test_reconnects_after_server_drop(): assert n >= 2 +def test_binary_frame_forwarded_to_handler(): + payload = bytes(range(128)) + + async def scenario(): + received: list[bytes] = [] + done = asyncio.Event() + + async def handler(ws): + await ws.send(payload) + done.set() + try: + await ws.wait_closed() + except Exception: + pass + + server, port = await _open_server(handler) + try: + client = WsClient( + f"ws://127.0.0.1:{port}", + token="", + heartbeat_interval=10.0, + reconnect_pause=0.05, + ) + + async def on_bin(data): + received.append(data) + + task = asyncio.create_task( + client.run( + on_message=lambda _m: asyncio.sleep(0), + heartbeat=lambda: {"type": "heartbeat"}, + on_binary=on_bin, + ) + ) + for _ in range(50): + if received: + break + await asyncio.sleep(0.02) + client.stop() + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass + finally: + server.close() + await server.wait_closed() + return received + + received = asyncio.run(scenario()) + assert received == [payload] + + +def test_binary_frame_dropped_when_no_handler(): + # Regression guard: existing behavior (drop server-sent binary) preserved when + # on_binary is not supplied. + async def scenario(): + crashes: list[Exception] = [] + + async def handler(ws): + await ws.send(b"\x00\x01\x02\x03") + await ws.send(json.dumps({"type": "ping"})) + try: + await ws.wait_closed() + except Exception: + pass + + messages: list[dict] = [] + server, port = await _open_server(handler) + try: + client = WsClient( + f"ws://127.0.0.1:{port}", + token="", + heartbeat_interval=10.0, + reconnect_pause=0.05, + ) + + async def on_msg(m): + messages.append(m) + + task = asyncio.create_task( + client.run(on_message=on_msg, heartbeat=lambda: {"type": "heartbeat"}) + ) + for _ in range(50): + if messages: + break + await asyncio.sleep(0.02) + client.stop() + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception) as exc: + crashes.append(exc) + finally: + server.close() + await server.wait_closed() + return messages, crashes + + messages, crashes = asyncio.run(scenario()) + # JSON still delivered; binary silently dropped; no uncaught crash. + assert messages and messages[0] == {"type": "ping"} + + def test_malformed_control_frame_does_not_crash(): async def scenario(): handled: list[dict] = [] From 8c247f9f7a7838d46ff1dff96485fbcbf1938430 Mon Sep 17 00:00:00 2001 From: jonny Date: Thu, 16 Apr 2026 15:12:56 -0400 Subject: [PATCH 21/43] transmit further updates --- scripts/pluto_tx_smoke.py | 225 +++++++++++++++++++++ scripts/pluto_tx_ws_smoke.py | 236 ++++++++++++++++++++++ src/ria_toolkit_oss/agent/hardware.py | 12 ++ src/ria_toolkit_oss/agent/streamer.py | 58 +++++- src/ria_toolkit_oss/sdr/pluto.py | 128 ++++++------ tests/agent/test_hardware.py | 27 +++ tests/agent/test_param_lock_contention.py | 210 +++++++++++++++++++ tests/agent/test_streamer.py | 15 ++ tests/agent/test_ws_client.py | 110 +--------- tests/agent/test_ws_client_binary.py | 186 +++++++++++++++++ 10 files changed, 1042 insertions(+), 165 deletions(-) create mode 100755 scripts/pluto_tx_smoke.py create mode 100755 scripts/pluto_tx_ws_smoke.py create mode 100644 tests/agent/test_param_lock_contention.py create mode 100644 tests/agent/test_ws_client_binary.py diff --git a/scripts/pluto_tx_smoke.py b/scripts/pluto_tx_smoke.py new file mode 100755 index 0000000..64adbb9 --- /dev/null +++ b/scripts/pluto_tx_smoke.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Transmit a continuous tone through the agent's TX pipeline on a real Pluto. + +End-to-end smoke test for the Pluto + Streamer TX path. Drives the same +``Streamer`` the hub talks to, but in-process with a logging ``FakeWs`` so +the script is self-contained — no hub required. + +Default: 100 kHz baseband tone × 2 450 MHz LO → carrier at 2 450.1 MHz, +continuous until you Ctrl-C (or the ``--duration`` timer fires). A spectrum +analyzer tuned to 2 450.1 MHz should show a clean CW spike as long as +``tx_status: transmitting`` prints. + +Usage:: + + python3 scripts/pluto_tx_smoke.py # auto-discover Pluto + python3 scripts/pluto_tx_smoke.py --identifier 192.168.3.1 + python3 scripts/pluto_tx_smoke.py --frequency 2.4e9 --gain -20 --duration 60 + +Flags map 1:1 onto the agent's ``radio_config``: + + --identifier Pluto IP or hostname (omitted → ip:pluto.local). + --frequency TX LO in Hz. Default 2 450 MHz. + --gain Pluto TX gain in dB. Pluto range is ``[-89, 0]``; more negative + = more attenuation = less power. Default -30. + --sample-rate Baseband sample rate. Default 1 MHz. + --tone Baseband tone offset in Hz. Default 100 kHz; set 0 for DC + (unmodulated carrier at exactly --frequency, but Pluto's + LO leakage will dominate). + --buffer-size Complex samples per WS frame. Default 4096. + --duration Stop after this many seconds (0 = run until Ctrl-C). +""" + +from __future__ import annotations + +import argparse +import asyncio +import logging +import signal +import sys + +import numpy as np + +from ria_toolkit_oss.agent.config import AgentConfig +from ria_toolkit_oss.agent.streamer import Streamer + + +class LoggingFakeWs: + """In-process stand-in for the hub's WebSocket. + + Prints every ``tx_status`` + ``error`` frame the Streamer emits so the + operator can watch the lifecycle (armed → transmitting → done) on stdout. + """ + + async def send_json(self, payload: dict) -> None: + t = payload.get("type") + if t == "tx_status": + state = payload.get("state") + msg = payload.get("message") + tail = f" — {msg}" if msg else "" + print(f"[tx_status] {state}{tail}") + elif t == "error": + print(f"[error] {payload.get('message')}") + + async def send_bytes(self, data: bytes) -> None: + # Agent side won't send RX bytes in this script (no RX session). + pass + + +def _make_iq_frame(buffer_size: int, tone_hz: float, sample_rate: float, + phase_offset: float = 0.0) -> tuple[bytes, float]: + """Return ``(interleaved_float32_bytes, next_phase)`` for a sine tone. + + Emitting one continuous phase-coherent tone requires threading the phase + across frames; the returned ``next_phase`` should be fed back as + ``phase_offset`` on the next call so the sinusoid doesn't glitch at frame + boundaries. Amplitude is 0.7 to leave some headroom below the [-1, 1] cap + that ``_verify_sample_format`` polices elsewhere in the toolkit. + """ + n = np.arange(buffer_size, dtype=np.float64) + phase = 2.0 * np.pi * tone_hz / sample_rate * n + phase_offset + amp = 0.7 + iq = amp * (np.cos(phase) + 1j * np.sin(phase)) + iq = iq.astype(np.complex64) + interleaved = np.empty(buffer_size * 2, dtype=np.float32) + interleaved[0::2] = iq.real + interleaved[1::2] = iq.imag + next_phase = (2.0 * np.pi * tone_hz / sample_rate * buffer_size + phase_offset) % (2.0 * np.pi) + return interleaved.tobytes(), next_phase + + +def _make_pluto_factory(identifier: str | None): + def factory(device: str, _ident: str | None): + if device != "pluto": + raise ValueError(f"this script only drives pluto; got device={device!r}") + from ria_toolkit_oss.sdr.pluto import Pluto + return Pluto(identifier=identifier) + return factory + + +async def _run(args: argparse.Namespace) -> int: + ws = LoggingFakeWs() + cfg = AgentConfig( + tx_enabled=True, + # Pluto's TX gain range is [-89, 0]. Cap at 0 so a fat-fingered + # --gain=+5 still gets rejected at the agent boundary rather than + # turned into mystery attenuation by Pluto's setter. + tx_max_gain_db=0.0, + tx_max_duration_s=float(args.duration) if args.duration > 0 else None, + ) + streamer = Streamer(ws=ws, sdr_factory=_make_pluto_factory(args.identifier), cfg=cfg) + + await streamer.on_message( + { + "type": "tx_start", + "app_id": "smoke", + "radio_config": { + "device": "pluto", + "identifier": args.identifier, + "tx_sample_rate": int(args.sample_rate), + "tx_center_frequency": int(args.frequency), + "tx_gain": int(args.gain), + "buffer_size": int(args.buffer_size), + # "repeat" keeps the last buffer on the air if we ever stall, + # so a continuous carrier stays up even when Python GC or + # asyncio scheduling briefly pauses the producer. + "underrun_policy": "repeat", + }, + } + ) + + # Abort if tx_start was rejected by an interlock (no session → nothing to do). + if streamer._tx is None: + print("tx_start rejected — see [tx_status] line above for the reason.", + file=sys.stderr) + return 2 + + print(f"Transmitting at {args.frequency/1e6:.3f} MHz with " + f"{args.tone/1e3:.1f} kHz baseband tone at gain {args.gain} dB. " + f"{'Running for ' + str(args.duration) + 's' if args.duration > 0 else 'Run until Ctrl-C'}.") + + # Arrange a clean shutdown on Ctrl-C. + stop = asyncio.Event() + loop = asyncio.get_running_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + try: + loop.add_signal_handler(sig, stop.set) + except NotImplementedError: + # add_signal_handler is not available on Windows event loops. + pass + + # Produce buffers at the nominal sample-rate pace. We deliberately stay + # slightly ahead of the radio — queue is bounded at 8, so backpressure + # flows naturally. + phase = 0.0 + buffer_dt = args.buffer_size / args.sample_rate + # Aim for one buffer every ``buffer_dt * 0.5`` seconds so the queue stays + # topped up. The queue's own backpressure keeps us from spinning. + produce_interval = buffer_dt * 0.5 + try: + async def producer(): + nonlocal phase + while not stop.is_set(): + frame, phase = _make_iq_frame( + args.buffer_size, args.tone, args.sample_rate, phase + ) + await streamer.on_binary(frame) + await asyncio.sleep(produce_interval) + + producer_task = asyncio.create_task(producer()) + + if args.duration > 0: + try: + await asyncio.wait_for(stop.wait(), timeout=args.duration) + except asyncio.TimeoutError: + pass + else: + await stop.wait() + + stop.set() + producer_task.cancel() + try: + await producer_task + except (asyncio.CancelledError, Exception): + pass + finally: + await streamer.on_message({"type": "tx_stop", "app_id": "smoke"}) + + print("TX session closed.") + return 0 + + +def main() -> int: + p = argparse.ArgumentParser( + description="End-to-end TX smoke test: agent → Pluto continuous tone.", + ) + p.add_argument("--identifier", default=None, + help="Pluto IP/hostname (default: auto-discover pluto.local)") + p.add_argument("--frequency", type=float, default=3_410_000_000.0, + help="TX LO in Hz (default 2.45 GHz)") + p.add_argument("--gain", type=float, default=-0.0, + help="TX gain in dB; Pluto range [-89, 0] (default -30)") + p.add_argument("--sample-rate", type=float, default=1_000_000.0, + help="Baseband sample rate (default 1 Msps)") + p.add_argument("--tone", type=float, default=100_000.0, + help="Baseband tone offset in Hz; 0 = DC (default 100 kHz)") + p.add_argument("--buffer-size", type=int, default=4096, + help="Complex samples per frame (default 4096)") + p.add_argument("--duration", type=float, default=60.0, + help="Seconds to transmit; 0 = run until Ctrl-C (default 30)") + p.add_argument("--log-level", default="INFO") + args = p.parse_args() + + logging.basicConfig( + level=getattr(logging, args.log_level.upper(), logging.INFO), + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + ) + + try: + return asyncio.run(_run(args)) + except KeyboardInterrupt: + return 130 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/pluto_tx_ws_smoke.py b/scripts/pluto_tx_ws_smoke.py new file mode 100755 index 0000000..d4c8344 --- /dev/null +++ b/scripts/pluto_tx_ws_smoke.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Full-stack TX smoke test: localhost mock-hub → WS → agent → real Pluto. + +Same radio output as ``pluto_tx_smoke.py`` (continuous tone at 2 450.1 MHz), +but drives the agent through the *real* WebSocket path instead of calling +handlers in-process. Proves that the hub-driven path behaves identically: + + mock hub ── ws:// ──▶ WsClient.run() ──▶ Streamer.on_message + └▶ Streamer.on_binary + │ + ▼ + real Pluto + +This is the most rigorous check short of pointing the real ``ria-agent stream`` +at a live ria-hub. If a tone appears on the spectrum analyzer here but *not* +when ria-hub drives it, the fault is above the WS decoder (registration, +capability gate, TX operator, hub's binary-frame publisher); everything +downstream of ``ws.recv()`` is this script's code path. + +Usage:: + + python3 scripts/pluto_tx_ws_smoke.py # default 30s tone + python3 scripts/pluto_tx_ws_smoke.py --identifier 192.168.3.1 + python3 scripts/pluto_tx_ws_smoke.py --duration 0 # until Ctrl-C +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import logging +import signal +import sys + +import numpy as np +import websockets + +from ria_toolkit_oss.agent.config import AgentConfig +from ria_toolkit_oss.agent.streamer import Streamer +from ria_toolkit_oss.agent.ws_client import WsClient + + +def _make_iq_frame(buffer_size: int, tone_hz: float, sample_rate: float, + phase_offset: float) -> tuple[bytes, float]: + n = np.arange(buffer_size, dtype=np.float64) + phase = 2.0 * np.pi * tone_hz / sample_rate * n + phase_offset + amp = 0.7 + iq = (amp * (np.cos(phase) + 1j * np.sin(phase))).astype(np.complex64) + interleaved = np.empty(buffer_size * 2, dtype=np.float32) + interleaved[0::2] = iq.real + interleaved[1::2] = iq.imag + next_phase = (2.0 * np.pi * tone_hz / sample_rate * buffer_size + phase_offset) % (2.0 * np.pi) + return interleaved.tobytes(), next_phase + + +def _make_pluto_factory(identifier: str | None): + def factory(device: str, _ident: str | None): + if device != "pluto": + raise ValueError(f"this script only drives pluto; got device={device!r}") + from ria_toolkit_oss.sdr.pluto import Pluto + return Pluto(identifier=identifier) + return factory + + +async def _mock_hub_handler(ws, args, stop: asyncio.Event): + """Server side of the WS. Sends tx_start, streams IQ, then tx_stop.""" + # Drain the first heartbeat so the log is clean; we don't need to gate on + # it for a localhost smoke test. + try: + first = await asyncio.wait_for(ws.recv(), timeout=2.0) + if isinstance(first, str): + payload = json.loads(first) + if payload.get("type") == "heartbeat": + caps = payload.get("capabilities") + print(f"[mock-hub] agent heartbeat: capabilities={caps} " + f"tx_enabled={payload.get('tx_enabled')}") + except asyncio.TimeoutError: + print("[mock-hub] warning: no heartbeat received in first 2s") + + # Arm the agent's TX path. + await ws.send(json.dumps({ + "type": "tx_start", + "app_id": "ws-smoke", + "radio_config": { + "device": "pluto", + "identifier": args.identifier, + "tx_sample_rate": int(args.sample_rate), + "tx_center_frequency": int(args.frequency), + "tx_gain": int(args.gain), + "buffer_size": int(args.buffer_size), + "underrun_policy": "repeat", + }, + })) + print(f"[mock-hub] sent tx_start at {args.frequency/1e6:.3f} MHz, " + f"gain={args.gain} dB") + + # Producer: push IQ frames at a steady clip. Use a concurrent receiver so + # tx_status frames show up in real time rather than being queued behind + # the sends. + phase = 0.0 + buffer_dt = args.buffer_size / args.sample_rate + + async def receiver(): + try: + while True: + msg = await ws.recv() + if isinstance(msg, str): + print(f"[mock-hub] ← {msg}") + except (websockets.ConnectionClosed, asyncio.CancelledError): + pass + + recv_task = asyncio.create_task(receiver()) + try: + deadline = None if args.duration <= 0 else ( + asyncio.get_event_loop().time() + args.duration + ) + while not stop.is_set(): + if deadline is not None and asyncio.get_event_loop().time() >= deadline: + break + frame, phase = _make_iq_frame( + args.buffer_size, args.tone, args.sample_rate, phase + ) + try: + await ws.send(frame) + except websockets.ConnectionClosed: + break + # Slightly ahead of real-time; WS backpressure handles the rest. + await asyncio.sleep(buffer_dt * 0.5) + finally: + try: + await ws.send(json.dumps({"type": "tx_stop", "app_id": "ws-smoke"})) + print("[mock-hub] sent tx_stop") + except websockets.ConnectionClosed: + pass + # Give the agent a moment to emit `tx_status: done` before we tear down. + await asyncio.sleep(0.3) + recv_task.cancel() + try: + await recv_task + except (asyncio.CancelledError, Exception): + pass + + +async def _run(args: argparse.Namespace) -> int: + stop = asyncio.Event() + loop = asyncio.get_running_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + try: + loop.add_signal_handler(sig, stop.set) + except NotImplementedError: + pass + + # Start the mock hub on a local port. + async def handler(ws): + try: + await _mock_hub_handler(ws, args, stop) + finally: + stop.set() + + server = await websockets.serve(handler, "127.0.0.1", 0) + port = server.sockets[0].getsockname()[1] + print(f"[mock-hub] listening on ws://127.0.0.1:{port}") + + # Run the agent — exactly as ``ria-agent stream`` would, just with a + # different URL and an in-memory AgentConfig instead of one loaded from + # ``~/.ria/agent.json``. + client = WsClient( + f"ws://127.0.0.1:{port}", + token="", + heartbeat_interval=5.0, + reconnect_pause=0.5, + ) + streamer = Streamer( + ws=client, + sdr_factory=_make_pluto_factory(args.identifier), + cfg=AgentConfig(tx_enabled=True, tx_max_gain_db=0.0), + ) + client_task = asyncio.create_task( + client.run( + on_message=streamer.on_message, + heartbeat=streamer.build_heartbeat, + on_binary=streamer.on_binary, + ) + ) + + try: + await stop.wait() + finally: + client.stop() + client_task.cancel() + try: + await client_task + except (asyncio.CancelledError, Exception): + pass + server.close() + await server.wait_closed() + + print("Done.") + return 0 + + +def main() -> int: + p = argparse.ArgumentParser( + description="Full-stack TX smoke: localhost mock-hub → WS → agent → Pluto.", + ) + p.add_argument("--identifier", default=None, + help="Pluto IP/hostname (default: auto-discover pluto.local)") + p.add_argument("--frequency", type=float, default=2_450_000_000.0, + help="TX LO in Hz (default 2.45 GHz)") + p.add_argument("--gain", type=float, default=0.0, + help="TX gain in dB; Pluto range [-89, 0] (default 0)") + p.add_argument("--sample-rate", type=float, default=1_000_000.0, + help="Baseband sample rate (default 1 Msps)") + p.add_argument("--tone", type=float, default=100_000.0, + help="Baseband tone offset in Hz (default 100 kHz)") + p.add_argument("--buffer-size", type=int, default=4096, + help="Complex samples per frame (default 4096)") + p.add_argument("--duration", type=float, default=30.0, + help="Seconds to transmit; 0 = run until Ctrl-C (default 30)") + p.add_argument("--log-level", default="INFO") + args = p.parse_args() + + logging.basicConfig( + level=getattr(logging, args.log_level.upper(), logging.INFO), + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + ) + + try: + return asyncio.run(_run(args)) + except KeyboardInterrupt: + return 130 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/ria_toolkit_oss/agent/hardware.py b/src/ria_toolkit_oss/agent/hardware.py index 32a65e5..d585e8f 100644 --- a/src/ria_toolkit_oss/agent/hardware.py +++ b/src/ria_toolkit_oss/agent/hardware.py @@ -37,6 +37,18 @@ def heartbeat_payload( "capabilities": capabilities, "tx_enabled": bool(c.tx_enabled), } + # Surface configured interlock values so the hub can pre-filter UI controls + # before sending a tx_start that would be rejected. Only included when TX + # is opted in AND the operator set a cap. + if c.tx_enabled: + if c.tx_max_gain_db is not None: + payload["tx_max_gain_db"] = float(c.tx_max_gain_db) + if c.tx_max_duration_s is not None: + payload["tx_max_duration_s"] = float(c.tx_max_duration_s) + if c.tx_allowed_freq_ranges: + payload["tx_allowed_freq_ranges"] = [ + [float(lo), float(hi)] for lo, hi in c.tx_allowed_freq_ranges + ] if app_id: payload["app_id"] = app_id if sessions: diff --git a/src/ria_toolkit_oss/agent/streamer.py b/src/ria_toolkit_oss/agent/streamer.py index 8570a73..6cf73e6 100644 --- a/src/ria_toolkit_oss/agent/streamer.py +++ b/src/ria_toolkit_oss/agent/streamer.py @@ -197,8 +197,14 @@ class Streamer: sessions=sessions or None, ) + # Advisory / keepalive message types we accept and ignore without warning. + _IGNORED_MESSAGE_TYPES = frozenset({"tx_data_available"}) + async def on_message(self, msg: dict) -> None: t = msg.get("type") + if t in self._IGNORED_MESSAGE_TYPES: + logger.debug("Ignoring advisory message: %r", t) + return handler = { "start": self._handle_rx_start, "stop": self._handle_rx_stop, @@ -469,9 +475,12 @@ class Streamer: def _tx_executor_body(self, session: TxSession) -> None: try: session.sdr._stream_tx(lambda n: self._tx_callback(session, n)) - except Exception: + except Exception as exc: logger.exception("TX stream crashed") - self._schedule(self._send_tx_status(session.app_id, "error", "tx stream crashed")) + # Schedule both the error frame and session teardown on the loop + # so ``self._tx`` clears, subsequent binary frames are rejected, + # and the SDR handle is released. + self._schedule(self._tx_crash_teardown(session, str(exc))) def _tx_callback(self, session: TxSession, num_samples) -> np.ndarray: n = int(num_samples) @@ -561,6 +570,18 @@ class Streamer: return await asyncio.sleep(0.05) + async def _tx_crash_teardown(self, session: TxSession, message: str) -> None: + # Called from the executor thread via _schedule when _stream_tx raises. + # Emit the error, mark stopped, drain the queue, release the SDR. + await self._send_tx_status(session.app_id, "error", f"tx stream crashed: {message}") + if self._tx is not session: + return + session.stop_event.set() + self._drain_tx_queue(session) + self._close_session_sdr(session) + if self._tx is session: + self._tx = None + async def _teardown_tx_after_underrun(self, session: TxSession) -> None: if self._tx is not session: return @@ -643,13 +664,44 @@ _CONFIG_ATTR_MAP = { } +def _is_stub_setter(method: Any) -> bool: + """True when *method* is an unimplemented base-class stub. + + The ``SDR`` abstract base defines ``set_rx_sample_rate`` / ``set_tx_gain`` + etc. as zero-argument ``NotImplementedError`` stubs. A driver (Pluto) that + actually transmits overrides them with a real ``(value, ...)`` signature. + Comparing ``__qualname__`` against ``SDR.`` lets us skip the stubs cheaply. + """ + return getattr(method, "__qualname__", "").startswith("SDR.") + + def _apply_sdr_config(sdr: Any, cfg: dict) -> None: - """Apply a radio_config dict to an SDR, trying multiple attribute aliases.""" + """Apply a radio_config dict to an SDR. + + Prefers ``sdr.set_(value)`` when the driver implements it — Pluto's + setters take ``_param_lock``, so routing through them keeps concurrent + RX + TX reconfigures from racing on shared native attributes. Falls back + to ``setattr`` for drivers (MockSDR, tests) that don't override the + base-class stubs. + """ for key, value in cfg.items(): if value is None: continue attrs = _CONFIG_ATTR_MAP.get(key, (key,)) applied = False + for attr in attrs: + setter = getattr(sdr, f"set_{attr}", None) + if callable(setter) and not _is_stub_setter(setter): + try: + setter(value) + applied = True + break + except Exception as exc: + logger.debug("set_%s(%r) failed: %s", attr, value, exc) + # Fall through to setattr; some drivers may partially + # implement setters. + if applied: + continue for attr in attrs: if hasattr(sdr, attr): try: diff --git a/src/ria_toolkit_oss/sdr/pluto.py b/src/ria_toolkit_oss/sdr/pluto.py index 7ed3be0..88243b1 100644 --- a/src/ria_toolkit_oss/sdr/pluto.py +++ b/src/ria_toolkit_oss/sdr/pluto.py @@ -384,7 +384,10 @@ class Pluto(SDR): self._enable_tx = True while self._enable_tx is True: buffer = self._convert_tx_samples(callback(self.tx_buffer_size)) - self.radio.tx(buffer[0]) + # pyadi-iio's ``radio.tx`` auto-wraps single-channel 1-D input. + # Indexing ``buffer[0]`` was a latent bug for callbacks that + # returned 1-D samples (scalar → TypeError inside pyadi). + self.radio.tx(buffer) def set_rx_center_frequency(self, center_frequency): """ @@ -514,74 +517,85 @@ class Pluto(SDR): raise SDRError(e) def set_tx_center_frequency(self, center_frequency): - if center_frequency < 70e6 or center_frequency > 6e9: - raise SDRParameterError( - f"{self.__class__.__name__}: Center frequency {center_frequency/1e9:.3f} GHz " - f"out of range:\nStandard:\t[{325e6/1e9:.3f} - {3.8e9/1e9:.3f} GHz]\nHacked:\t" - f"[{70e6/1e9:.3f} - {6e9/1e9:.3f} GHz]" - ) + # ``adi.Pluto`` exposes one radio handle shared between RX and TX; concurrent + # RX + TX sessions (see the agent ``_SdrRegistry``) may call RX and TX + # setters at the same time. Serialize with ``_param_lock`` — RX setters hold + # the same reentrant lock — so native attribute writes don't interleave. + with self._param_lock: + if center_frequency < 70e6 or center_frequency > 6e9: + raise SDRParameterError( + f"{self.__class__.__name__}: Center frequency {center_frequency/1e9:.3f} GHz " + f"out of range:\nStandard:\t[{325e6/1e9:.3f} - {3.8e9/1e9:.3f} GHz]\nHacked:\t" + f"[{70e6/1e9:.3f} - {6e9/1e9:.3f} GHz]" + ) - try: - self.radio.tx_lo = int(center_frequency) - self.tx_center_frequency = center_frequency - except OSError as e: - raise SDRError(e) - except ValueError: - raise SDRParameterError( - f"{self.__class__.__name__}: Center frequency {center_frequency/1e9:.3f} GHz " - f"out of range:\nStandard:\t[{325e6/1e9:.3f} - {3.8e9/1e9:.3f} GHz]\nHacked:\t" - f"[{70e6/1e9:.3f} - {6e9/1e9:.3f} GHz]" - ) + try: + self.radio.tx_lo = int(center_frequency) + self.tx_center_frequency = center_frequency + except OSError as e: + raise SDRError(e) + except ValueError: + raise SDRParameterError( + f"{self.__class__.__name__}: Center frequency {center_frequency/1e9:.3f} GHz " + f"out of range:\nStandard:\t[{325e6/1e9:.3f} - {3.8e9/1e9:.3f} GHz]\nHacked:\t" + f"[{70e6/1e9:.3f} - {6e9/1e9:.3f} GHz]" + ) def set_tx_sample_rate(self, sample_rate): - min_rate, max_rate = 65.1e3, 61.44e6 - if sample_rate < min_rate or sample_rate > max_rate: - raise SDRParameterError( - f"{self.__class__.__name__}: Sample rate {sample_rate/1e6:.3f} Msps " - f"out of range: [{min_rate/1e6:.3f} - {max_rate/1e6:.3f} Msps]" - ) + # ``self.radio.sample_rate`` is shared between RX and TX on Pluto — RX's + # ``set_rx_sample_rate`` writes the same native attribute. Hold ``_param_lock`` + # so full-duplex sessions can't interleave writes. + with self._param_lock: + min_rate, max_rate = 65.1e3, 61.44e6 + if sample_rate < min_rate or sample_rate > max_rate: + raise SDRParameterError( + f"{self.__class__.__name__}: Sample rate {sample_rate/1e6:.3f} Msps " + f"out of range: [{min_rate/1e6:.3f} - {max_rate/1e6:.3f} Msps]" + ) - try: - self.radio.sample_rate = sample_rate - self.tx_sample_rate = sample_rate - except OSError as e: - raise SDRError(e) - except ValueError: - raise SDRParameterError( - f"{self.__class__.__name__}: Sample rate {sample_rate/1e6:.3f} Msps " - f"out of range: [{min_rate/1e6:.3f} - {max_rate/1e6:.3f} Msps]" - ) + try: + self.radio.sample_rate = sample_rate + self.tx_sample_rate = sample_rate + except OSError as e: + raise SDRError(e) + except ValueError: + raise SDRParameterError( + f"{self.__class__.__name__}: Sample rate {sample_rate/1e6:.3f} Msps " + f"out of range: [{min_rate/1e6:.3f} - {max_rate/1e6:.3f} Msps]" + ) def set_tx_gain(self, gain, channel=0, gain_mode="absolute"): - tx_gain_min = -89 - tx_gain_max = 0 + # Serialize with RX setters: see ``set_tx_sample_rate`` above. + with self._param_lock: + tx_gain_min = -89 + tx_gain_max = 0 - if gain_mode == "relative": - if gain > 0: - raise SDRParameterError("When gain_mode = 'relative', gain must be < 0. This sets\ - the gain relative to the maximum possible gain.") + if gain_mode == "relative": + if gain > 0: + raise SDRParameterError("When gain_mode = 'relative', gain must be < 0. This sets\ + the gain relative to the maximum possible gain.") + else: + abs_gain = tx_gain_max + gain else: - abs_gain = tx_gain_max + gain - else: - abs_gain = gain + abs_gain = gain - if abs_gain < tx_gain_min or abs_gain > tx_gain_max: - abs_gain = min(max(gain, tx_gain_min), tx_gain_max) - print(f"Gain {gain} out of range for Pluto.") - print(f"Gain range: {tx_gain_min} to {tx_gain_max} dB") + if abs_gain < tx_gain_min or abs_gain > tx_gain_max: + abs_gain = min(max(gain, tx_gain_min), tx_gain_max) + print(f"Gain {gain} out of range for Pluto.") + print(f"Gain range: {tx_gain_min} to {tx_gain_max} dB") - try: - self.tx_gain = abs_gain + try: + self.tx_gain = abs_gain - if channel == 0: - self.radio.tx_hardwaregain_chan0 = int(abs_gain) - elif channel == 1: - self.radio.tx_hardwaregain_chan1 = int(abs_gain) - else: - raise SDRParameterError(f"Pluto channel must be 0 or 1 but was {channel}.") + if channel == 0: + self.radio.tx_hardwaregain_chan0 = int(abs_gain) + elif channel == 1: + self.radio.tx_hardwaregain_chan1 = int(abs_gain) + else: + raise SDRParameterError(f"Pluto channel must be 0 or 1 but was {channel}.") - except Exception as e: - raise SDRError(e) + except Exception as e: + raise SDRError(e) def set_tx_channel(self, channel): if channel == 0: diff --git a/tests/agent/test_hardware.py b/tests/agent/test_hardware.py index 51b2e45..6a9cdf3 100644 --- a/tests/agent/test_hardware.py +++ b/tests/agent/test_hardware.py @@ -44,3 +44,30 @@ def test_heartbeat_payload_sessions_field(): sessions = {"rx": {"app_id": "a", "state": "streaming"}} p = hardware.heartbeat_payload(status="streaming", app_id="a", sessions=sessions) assert p["sessions"] == sessions + + +def test_heartbeat_payload_surfaces_tx_caps_when_enabled(): + from ria_toolkit_oss.agent.config import AgentConfig + + cfg = AgentConfig( + tx_enabled=True, + tx_max_gain_db=-10.0, + tx_max_duration_s=60.0, + tx_allowed_freq_ranges=[[2.4e9, 2.5e9], [5.7e9, 5.8e9]], + ) + p = hardware.heartbeat_payload(cfg=cfg) + assert p["tx_max_gain_db"] == -10.0 + assert p["tx_max_duration_s"] == 60.0 + assert p["tx_allowed_freq_ranges"] == [[2.4e9, 2.5e9], [5.7e9, 5.8e9]] + + +def test_heartbeat_payload_omits_caps_when_tx_disabled(): + from ria_toolkit_oss.agent.config import AgentConfig + + # Caps set but tx_enabled=False — don't leak them; they're only meaningful + # when the hub can attempt a tx_start. + cfg = AgentConfig(tx_enabled=False, tx_max_gain_db=-10.0) + p = hardware.heartbeat_payload(cfg=cfg) + assert "tx_max_gain_db" not in p + assert "tx_max_duration_s" not in p + assert "tx_allowed_freq_ranges" not in p diff --git a/tests/agent/test_param_lock_contention.py b/tests/agent/test_param_lock_contention.py new file mode 100644 index 0000000..e3d84fc --- /dev/null +++ b/tests/agent/test_param_lock_contention.py @@ -0,0 +1,210 @@ +"""Step-A6 (Pluto lock audit) coverage. + +Verifies the two invariants the handoff doc calls for when RX and TX run +concurrently on one shared SDR handle: + +1. ``_param_lock`` actually serializes concurrent RX + TX setter calls — the + spec's §A6 acceptance criterion is *"``_param_lock`` instrumented for + contention"*. We drive parallel ``set_{rx,tx}_sample_rate`` calls through + the lock and assert it's hit often enough to prove both paths fight for it. +2. Under a sustained full-duplex session (RX capturing + TX transmitting on + one ``(device, identifier)``), no setter write is dropped and no exception + escapes the executor — i.e., the shared-handle assumption holds. Runs + against ``MockSDR`` per the spec; the real Pluto driver now takes the + same lock on its TX setters so the production code path is isomorphic. + +The stress window is 2 seconds by default — the handoff mentions 30 s but +that's impractical in CI. Set ``RIA_LOCK_STRESS_S`` to override. +""" + +from __future__ import annotations + +import asyncio +import os +import threading +import time + +import numpy as np + +from ria_toolkit_oss.agent.config import AgentConfig +from ria_toolkit_oss.agent.streamer import Streamer +from ria_toolkit_oss.sdr.mock import MockSDR + + +_STRESS_S = float(os.environ.get("RIA_LOCK_STRESS_S", "2.0")) + + +class InstrumentedMockSDR(MockSDR): + """MockSDR that counts lock acquisitions and exposes a real ``_param_lock``. + + ``_param_lock`` is inherited from ``SDR`` as a reentrant lock; we wrap it + with a counter that records every time RX or TX setters grab it, so the + test can assert real contention rather than just "the code compiles". + """ + + def __init__(self, buffer_size: int): + super().__init__(buffer_size=buffer_size) + self.rx_lock_hits = 0 + self.tx_lock_hits = 0 + self.param_lock_hits = 0 + # Shadow lock that increments a counter each time __enter__ fires. + real_lock = self._param_lock + + test = self + + class CountingLock: + def __enter__(self_inner): + test.param_lock_hits += 1 + real_lock.acquire() + return self_inner + + def __exit__(self_inner, *a): + real_lock.release() + return False + + # ``threading.RLock`` interop for any code that calls acquire/release directly. + def acquire(self_inner, *a, **k): + test.param_lock_hits += 1 + return real_lock.acquire(*a, **k) + + def release(self_inner): + return real_lock.release() + + self._param_lock = CountingLock() + + # The MockSDR doesn't ship RX setter methods that hit the lock — override + # ``sample_rate`` / ``center_freq`` / ``gain`` writes to route through the + # same lock the real Pluto driver uses, so this test faithfully models the + # production contention path. + def set_rx_sample_rate(self, sample_rate): + with self._param_lock: + self.rx_lock_hits += 1 + self.rx_sample_rate = float(sample_rate) + self.sample_rate = self.rx_sample_rate + + def set_tx_sample_rate(self, sample_rate): + with self._param_lock: + self.tx_lock_hits += 1 + self.tx_sample_rate = float(sample_rate) + # Mirror Pluto: both RX and TX write the same native attribute. + self.sample_rate = self.tx_sample_rate + + +class FakeWs: + def __init__(self): + self.json_sent: list[dict] = [] + self.bytes_sent: list[bytes] = [] + + async def send_json(self, p): + self.json_sent.append(p) + + async def send_bytes(self, b): + self.bytes_sent.append(b) + + +def _iq_frame(samples: np.ndarray) -> bytes: + interleaved = np.empty(samples.size * 2, dtype=np.float32) + interleaved[0::2] = samples.real + interleaved[1::2] = samples.imag + return interleaved.tobytes() + + +def test_param_lock_contended_under_concurrent_setters(): + """Run two threads that hammer RX + TX sample-rate setters and assert both + lock paths fire. This proves the lock is doing work — if either setter + bypassed ``_param_lock``, one of the counters would stay at zero.""" + sdr = InstrumentedMockSDR(buffer_size=16) + stop = threading.Event() + + def rx_setter(): + i = 0 + while not stop.is_set(): + sdr.set_rx_sample_rate(1_000_000 + (i % 1000)) + i += 1 + + def tx_setter(): + i = 0 + while not stop.is_set(): + sdr.set_tx_sample_rate(2_000_000 + (i % 1000)) + i += 1 + + t1 = threading.Thread(target=rx_setter) + t2 = threading.Thread(target=tx_setter) + t1.start() + t2.start() + time.sleep(min(_STRESS_S, 2.0)) + stop.set() + t1.join() + t2.join() + + assert sdr.rx_lock_hits > 100, f"RX setter barely ran: {sdr.rx_lock_hits}" + assert sdr.tx_lock_hits > 100, f"TX setter barely ran: {sdr.tx_lock_hits}" + # Every setter call should have passed through _param_lock exactly once. + assert sdr.param_lock_hits >= sdr.rx_lock_hits + sdr.tx_lock_hits + + +def test_full_duplex_stays_healthy_over_stress_window(): + """Start RX + TX on one shared SDR and drive both paths for ``_STRESS_S`` + seconds, pushing binary frames and emitting ``tx_configure`` mid-stream. + The session must survive, deliver buffers in both directions, and leave + the registry clean on shutdown.""" + BUF = 32 + sdr = InstrumentedMockSDR(buffer_size=BUF) + + async def scenario(): + ws = FakeWs() + s = Streamer(ws=ws, sdr_factory=lambda d, i: sdr, cfg=AgentConfig(tx_enabled=True)) + + await s.on_message( + {"type": "start", "app_id": "app-1", + "radio_config": {"device": "mock", "buffer_size": BUF}} + ) + await s.on_message( + {"type": "tx_start", "app_id": "app-1", + "radio_config": { + "device": "mock", "buffer_size": BUF, + "tx_sample_rate": 1_000_000, + "tx_center_frequency": 2.45e9, + "tx_gain": -20, + "underrun_policy": "zero", + }} + ) + + marker = np.arange(BUF, dtype=np.complex64) + 1 + deadline = time.monotonic() + _STRESS_S + i = 0 + while time.monotonic() < deadline: + await s.on_binary(_iq_frame(marker)) + if i % 8 == 0: + # Mid-stream parameter reconfiguration touches _apply_sdr_config, + # which routes through the same setters the stress test above + # verifies. + await s.on_message( + {"type": "tx_configure", "app_id": "app-1", + "radio_config": {"tx_sample_rate": 1_000_000 + i}} + ) + await s.on_message( + {"type": "configure", "app_id": "app-1", + "radio_config": {"sample_rate": 2_000_000 + i}} + ) + i += 1 + await asyncio.sleep(0.005) + + await s.on_message({"type": "tx_stop", "app_id": "app-1"}) + await s.on_message({"type": "stop", "app_id": "app-1"}) + return ws, s + + ws, s = asyncio.run(scenario()) + + # No error frame leaked out. + errors = [m for m in ws.json_sent + if m.get("type") in ("error", "tx_status") and m.get("state") == "error"] + assert errors == [], f"Unexpected error frames: {errors}" + # RX produced IQ frames and TX's callback ran — heartbeat-level contention + # check: both setter paths were hit at least once during configure dispatch. + assert ws.bytes_sent, "RX produced no IQ frames" + assert sdr.param_lock_hits > 0 + # Sessions cleaned up; registry drained. + assert s._tx is None + assert s._rx is None + assert s._registry.refcount(("mock", None)) == 0 diff --git a/tests/agent/test_streamer.py b/tests/agent/test_streamer.py index 2aa842e..da2956c 100644 --- a/tests/agent/test_streamer.py +++ b/tests/agent/test_streamer.py @@ -139,6 +139,21 @@ def test_unknown_message_type_is_ignored(): asyncio.run(scenario()) +def test_tx_data_available_is_a_silent_noop(): + # Hub sends this as a keepalive; we should accept and ignore without + # emitting a WARNING or treating it as an error. + async def scenario(): + ws = FakeWs() + s = Streamer(ws=ws, sdr_factory=_factory) + await s.on_message({"type": "tx_data_available", "app_id": "x"}) + return ws + + ws = asyncio.run(scenario()) + # No outbound frames emitted. + assert ws.json_sent == [] + assert ws.bytes_sent == [] + + def test_registry_shares_sdr_across_start_stop_cycles(): # Two sequential start/stop cycles with the same (device, identifier) # should hit the registry's cache path rather than constructing a new SDR. diff --git a/tests/agent/test_ws_client.py b/tests/agent/test_ws_client.py index 4061f32..c113b64 100644 --- a/tests/agent/test_ws_client.py +++ b/tests/agent/test_ws_client.py @@ -1,11 +1,14 @@ -"""Reconnect + heartbeat timing against a real local websockets server.""" +"""Reconnect + heartbeat + malformed-control-frame behavior. + +Binary-frame delivery lives in ``test_ws_client_binary.py`` to match the +test matrix spelled out in ``Agent TX Streaming Handoff.md`` §A7. +""" from __future__ import annotations import asyncio import json -import pytest import websockets from ria_toolkit_oss.agent.ws_client import WsClient @@ -113,109 +116,6 @@ def test_reconnects_after_server_drop(): assert n >= 2 -def test_binary_frame_forwarded_to_handler(): - payload = bytes(range(128)) - - async def scenario(): - received: list[bytes] = [] - done = asyncio.Event() - - async def handler(ws): - await ws.send(payload) - done.set() - try: - await ws.wait_closed() - except Exception: - pass - - server, port = await _open_server(handler) - try: - client = WsClient( - f"ws://127.0.0.1:{port}", - token="", - heartbeat_interval=10.0, - reconnect_pause=0.05, - ) - - async def on_bin(data): - received.append(data) - - task = asyncio.create_task( - client.run( - on_message=lambda _m: asyncio.sleep(0), - heartbeat=lambda: {"type": "heartbeat"}, - on_binary=on_bin, - ) - ) - for _ in range(50): - if received: - break - await asyncio.sleep(0.02) - client.stop() - task.cancel() - try: - await task - except (asyncio.CancelledError, Exception): - pass - finally: - server.close() - await server.wait_closed() - return received - - received = asyncio.run(scenario()) - assert received == [payload] - - -def test_binary_frame_dropped_when_no_handler(): - # Regression guard: existing behavior (drop server-sent binary) preserved when - # on_binary is not supplied. - async def scenario(): - crashes: list[Exception] = [] - - async def handler(ws): - await ws.send(b"\x00\x01\x02\x03") - await ws.send(json.dumps({"type": "ping"})) - try: - await ws.wait_closed() - except Exception: - pass - - messages: list[dict] = [] - server, port = await _open_server(handler) - try: - client = WsClient( - f"ws://127.0.0.1:{port}", - token="", - heartbeat_interval=10.0, - reconnect_pause=0.05, - ) - - async def on_msg(m): - messages.append(m) - - task = asyncio.create_task( - client.run(on_message=on_msg, heartbeat=lambda: {"type": "heartbeat"}) - ) - for _ in range(50): - if messages: - break - await asyncio.sleep(0.02) - client.stop() - task.cancel() - try: - await task - except (asyncio.CancelledError, Exception) as exc: - crashes.append(exc) - finally: - server.close() - await server.wait_closed() - return messages, crashes - - messages, crashes = asyncio.run(scenario()) - # JSON still delivered; binary silently dropped; no uncaught crash. - assert messages and messages[0] == {"type": "ping"} - - def test_malformed_control_frame_does_not_crash(): async def scenario(): handled: list[dict] = [] diff --git a/tests/agent/test_ws_client_binary.py b/tests/agent/test_ws_client_binary.py new file mode 100644 index 0000000..4d9ddc1 --- /dev/null +++ b/tests/agent/test_ws_client_binary.py @@ -0,0 +1,186 @@ +"""Binary-frame delivery on the hub → agent WebSocket. + +Named to match the test matrix in ``Agent TX Streaming Handoff.md`` §A7. +Exercises: + +- Binary frames are forwarded to an ``on_binary`` coroutine when supplied. +- Binary frames are silently dropped (no crash) when ``on_binary`` is omitted, + preserving the pre-TX behavior for RX-only deployments. +""" + +from __future__ import annotations + +import asyncio +import json + +import websockets + +from ria_toolkit_oss.agent.ws_client import WsClient + + +async def _open_server(handler): + server = await websockets.serve(handler, "127.0.0.1", 0) + port = server.sockets[0].getsockname()[1] + return server, port + + +def test_binary_frame_forwarded_to_handler(): + payload = bytes(range(128)) + + async def scenario(): + received: list[bytes] = [] + done = asyncio.Event() + + async def handler(ws): + await ws.send(payload) + done.set() + try: + await ws.wait_closed() + except Exception: + pass + + server, port = await _open_server(handler) + try: + client = WsClient( + f"ws://127.0.0.1:{port}", + token="", + heartbeat_interval=10.0, + reconnect_pause=0.05, + ) + + async def on_bin(data): + received.append(data) + + task = asyncio.create_task( + client.run( + on_message=lambda _m: asyncio.sleep(0), + heartbeat=lambda: {"type": "heartbeat"}, + on_binary=on_bin, + ) + ) + for _ in range(50): + if received: + break + await asyncio.sleep(0.02) + client.stop() + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass + finally: + server.close() + await server.wait_closed() + return received + + received = asyncio.run(scenario()) + assert received == [payload] + + +def test_binary_frame_dropped_when_no_handler(): + async def scenario(): + crashes: list[Exception] = [] + + async def handler(ws): + await ws.send(b"\x00\x01\x02\x03") + await ws.send(json.dumps({"type": "ping"})) + try: + await ws.wait_closed() + except Exception: + pass + + messages: list[dict] = [] + server, port = await _open_server(handler) + try: + client = WsClient( + f"ws://127.0.0.1:{port}", + token="", + heartbeat_interval=10.0, + reconnect_pause=0.05, + ) + + async def on_msg(m): + messages.append(m) + + task = asyncio.create_task( + client.run(on_message=on_msg, heartbeat=lambda: {"type": "heartbeat"}) + ) + for _ in range(50): + if messages: + break + await asyncio.sleep(0.02) + client.stop() + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception) as exc: + crashes.append(exc) + finally: + server.close() + await server.wait_closed() + return messages, crashes + + messages, _ = asyncio.run(scenario()) + assert messages and messages[0] == {"type": "ping"} + + +def test_on_binary_exception_does_not_kill_connection(): + """A buggy ``on_binary`` raises mid-stream; the WS loop keeps accepting frames.""" + + async def scenario(): + delivered_binary = 0 + delivered_control: list[dict] = [] + + async def handler(ws): + await ws.send(b"\x10\x20\x30") + await ws.send(b"\x40\x50\x60") + await ws.send(json.dumps({"type": "ping"})) + try: + await ws.wait_closed() + except Exception: + pass + + server, port = await _open_server(handler) + try: + client = WsClient( + f"ws://127.0.0.1:{port}", + token="", + heartbeat_interval=10.0, + reconnect_pause=0.05, + ) + + async def on_bin(data): + nonlocal delivered_binary + delivered_binary += 1 + raise RuntimeError("handler broke") + + async def on_msg(m): + delivered_control.append(m) + + task = asyncio.create_task( + client.run( + on_message=on_msg, + heartbeat=lambda: {"type": "heartbeat"}, + on_binary=on_bin, + ) + ) + for _ in range(60): + if delivered_control: + break + await asyncio.sleep(0.02) + client.stop() + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass + finally: + server.close() + await server.wait_closed() + return delivered_binary, delivered_control + + bins, ctrls = asyncio.run(scenario()) + # Both binary frames were delivered to the (crashing) handler. + assert bins == 2 + # The subsequent JSON frame still arrived — loop didn't die on the exceptions. + assert ctrls and ctrls[0] == {"type": "ping"} From 5035f0654a05da3ab756ad0c9764b10a38ae93f0 Mon Sep 17 00:00:00 2001 From: jonny Date: Thu, 16 Apr 2026 15:38:35 -0400 Subject: [PATCH 22/43] tx_race_condtion_fix --- src/ria_toolkit_oss/agent/streamer.py | 26 +++++++++++++++++--------- tests/agent/test_full_duplex.py | 1 + tests/agent/test_streamer_tx.py | 9 ++++++++- tests/agent/test_tx_safety.py | 9 ++++++++- tests/agent/test_tx_underrun.py | 1 + 5 files changed, 35 insertions(+), 11 deletions(-) diff --git a/src/ria_toolkit_oss/agent/streamer.py b/src/ria_toolkit_oss/agent/streamer.py index 6cf73e6..51f1dce 100644 --- a/src/ria_toolkit_oss/agent/streamer.py +++ b/src/ria_toolkit_oss/agent/streamer.py @@ -396,15 +396,23 @@ class Streamer: try: sdr, device_key = self._registry.acquire(device, identifier) _apply_sdr_config(sdr, radio_config) - # Only call init_tx when the hub supplied the three required - # parameters. Drivers that gate _stream_tx on _tx_initialized - # (e.g. Pluto) need this; drivers that don't (e.g. Mock) tolerate - # its absence. - init_args = { - k: radio_config.get(f"tx_{k}") - for k in ("sample_rate", "center_frequency", "gain") - } - if hasattr(sdr, "init_tx") and all(v is not None for v in init_args.values()): + # init_tx is mandatory for any driver that exposes it: drivers + # that gate _stream_tx on _tx_initialized (Pluto, HackRF, USRP, + # …) crash with a confusing "TX was not initialized" error 2 s + # later in the executor thread if we skip it. Treat the three + # required keys as a hard contract — a missing one is a hub-side + # manifest bug and we want it surfaced immediately, not papered + # over with stale radio state. + if hasattr(sdr, "init_tx"): + init_args = { + k: radio_config.get(f"tx_{k}") + for k in ("sample_rate", "center_frequency", "gain") + } + missing = [f"tx_{k}" for k, v in init_args.items() if v is None] + if missing: + raise ValueError( + f"tx_start missing required radio_config keys: {missing}" + ) sdr.init_tx( sample_rate=init_args["sample_rate"], center_frequency=init_args["center_frequency"], diff --git a/tests/agent/test_full_duplex.py b/tests/agent/test_full_duplex.py index 6ad2f62..05de3c1 100644 --- a/tests/agent/test_full_duplex.py +++ b/tests/agent/test_full_duplex.py @@ -75,6 +75,7 @@ def test_rx_and_tx_share_one_sdr_instance(): "radio_config": { "device": "mock", "buffer_size": 16, + "tx_sample_rate": 1_000_000, "tx_gain": -20, "tx_center_frequency": 2.45e9, "underrun_policy": "zero", diff --git a/tests/agent/test_streamer_tx.py b/tests/agent/test_streamer_tx.py index 6cb2bb4..ea1ba5b 100644 --- a/tests/agent/test_streamer_tx.py +++ b/tests/agent/test_streamer_tx.py @@ -120,7 +120,14 @@ def test_tx_stop_releases_sdr(): { "type": "tx_start", "app_id": "a", - "radio_config": {"device": "mock", "buffer_size": 8, "underrun_policy": "zero"}, + "radio_config": { + "device": "mock", + "buffer_size": 8, + "tx_sample_rate": 1_000_000, + "tx_center_frequency": 2.45e9, + "tx_gain": -20, + "underrun_policy": "zero", + }, } ) await asyncio.sleep(0.03) diff --git a/tests/agent/test_tx_safety.py b/tests/agent/test_tx_safety.py index 5307917..2de2939 100644 --- a/tests/agent/test_tx_safety.py +++ b/tests/agent/test_tx_safety.py @@ -27,7 +27,14 @@ def _last_tx_status(ws): def _tx_start(app_id="a", **radio): - rc = {"device": "mock", "buffer_size": 16, "underrun_policy": "zero"} + rc = { + "device": "mock", + "buffer_size": 16, + "tx_sample_rate": 1_000_000, + "tx_center_frequency": 2.45e9, + "tx_gain": -20, + "underrun_policy": "zero", + } rc.update(radio) return {"type": "tx_start", "app_id": app_id, "radio_config": rc} diff --git a/tests/agent/test_tx_underrun.py b/tests/agent/test_tx_underrun.py index e95feec..8fbe020 100644 --- a/tests/agent/test_tx_underrun.py +++ b/tests/agent/test_tx_underrun.py @@ -52,6 +52,7 @@ def _start_cfg(policy: str, buf: int = 8) -> dict: "radio_config": { "device": "mock", "buffer_size": buf, + "tx_sample_rate": 1_000_000, "tx_gain": -20, "tx_center_frequency": 2.45e9, "underrun_policy": policy, From efc09481104e9c1fddccbab27a8bc1ce9fce7ed8 Mon Sep 17 00:00:00 2001 From: ben Date: Fri, 17 Apr 2026 09:43:59 -0400 Subject: [PATCH 23/43] ria composer support --- src/ria_toolkit_oss/orchestration/campaign.py | 6 +- src/ria_toolkit_oss/orchestration/executor.py | 61 ++- .../remote_control/__init__.py | 6 + .../remote_control/remote_transmitter.py | 147 +++++++ .../remote_transmitter_controller.py | 210 ++++++++++ tests/remote_control/__init__.py | 0 .../remote_control/test_remote_transmitter.py | 266 ++++++++++++ .../test_remote_transmitter_controller.py | 294 +++++++++++++ .../test_sdr_remote_integration.py | 391 ++++++++++++++++++ 9 files changed, 1379 insertions(+), 2 deletions(-) create mode 100644 src/ria_toolkit_oss/remote_control/__init__.py create mode 100644 src/ria_toolkit_oss/remote_control/remote_transmitter.py create mode 100644 src/ria_toolkit_oss/remote_control/remote_transmitter_controller.py create mode 100644 tests/remote_control/__init__.py create mode 100644 tests/remote_control/test_remote_transmitter.py create mode 100644 tests/remote_control/test_remote_transmitter_controller.py create mode 100644 tests/remote_control/test_sdr_remote_integration.py diff --git a/src/ria_toolkit_oss/orchestration/campaign.py b/src/ria_toolkit_oss/orchestration/campaign.py index 9d96c96..027c33f 100644 --- a/src/ria_toolkit_oss/orchestration/campaign.py +++ b/src/ria_toolkit_oss/orchestration/campaign.py @@ -223,13 +223,16 @@ class TransmitterConfig: id: str type: str # "wifi", "bluetooth", "sdr", "external" - control_method: str # "external_script" | "sdr" + control_method: str # "external_script" | "sdr" | "sdr_remote" schedule: list[CaptureStep] # For external_script control script: Optional[str] = None # path to control script device: Optional[str] = None # e.g. "/dev/wlan0" + # For sdr_remote control — keys: host, ssh_user, ssh_key_path, device_type, device_id, zmq_port + sdr_remote: Optional[dict] = None + @classmethod def from_dict(cls, d: dict) -> "TransmitterConfig": schedule = [CaptureStep.from_dict(s) for s in d.get("schedule", [])] @@ -240,6 +243,7 @@ class TransmitterConfig: schedule=schedule, script=d.get("script"), device=d.get("device"), + sdr_remote=d.get("sdr_remote"), ) diff --git a/src/ria_toolkit_oss/orchestration/executor.py b/src/ria_toolkit_oss/orchestration/executor.py index 629c0d8..1bdd4d8 100644 --- a/src/ria_toolkit_oss/orchestration/executor.py +++ b/src/ria_toolkit_oss/orchestration/executor.py @@ -196,6 +196,7 @@ class CampaignExecutor: self.config = config self.progress_cb = progress_cb self._sdr = None + self._remote_tx_controllers: dict = {} if verbose: logging.basicConfig(level=logging.DEBUG) @@ -222,6 +223,7 @@ class CampaignExecutor: ) self._init_sdr() + self._init_remote_tx_controllers() try: total = self.config.total_steps() step_index = 0 @@ -248,6 +250,7 @@ class CampaignExecutor: ) finally: self._close_sdr() + self._close_remote_tx_controllers() result.end_time = time.time() logger.info( @@ -287,6 +290,41 @@ class CampaignExecutor: logger.warning(f"SDR close error: {e}") self._sdr = None + # ------------------------------------------------------------------ + # Remote Tx controller management + # ------------------------------------------------------------------ + + def _init_remote_tx_controllers(self) -> None: + """Open SSH+ZMQ connections for all sdr_remote transmitters.""" + from ria_toolkit_oss.remote_control import RemoteTransmitterController + + for tx in self.config.transmitters: + if tx.control_method != "sdr_remote": + continue + cfg = tx.sdr_remote + if not cfg: + raise RuntimeError(f"Transmitter '{tx.id}' uses sdr_remote but has no sdr_remote config") + logger.info(f"Connecting remote Tx controller for {tx.id} → {cfg['host']}") + ctrl = RemoteTransmitterController( + host=cfg["host"], + ssh_user=cfg["ssh_user"], + ssh_key_path=cfg["ssh_key_path"], + zmq_port=int(cfg.get("zmq_port", 5556)), + ) + ctrl.set_radio( + device_type=cfg["device_type"], + device_id=cfg.get("device_id", ""), + ) + self._remote_tx_controllers[tx.id] = ctrl + + def _close_remote_tx_controllers(self) -> None: + for tx_id, ctrl in list(self._remote_tx_controllers.items()): + try: + ctrl.close() + except Exception as exc: + logger.warning(f"Error closing remote Tx controller {tx_id}: {exc}") + self._remote_tx_controllers.clear() + def _record(self, duration_s: float) -> Recording: """Capture ``duration_s`` seconds of IQ samples.""" num_samples = int(duration_s * self.config.recorder.sample_rate) @@ -372,7 +410,8 @@ class CampaignExecutor: traffic, etc. The script is responsible for applying the configuration and returning promptly (i.e. not blocking for the capture duration). - For SDR transmitters this is a no-op placeholder (TX not yet implemented). + For ``sdr_remote`` the remote ZMQ controller calls ``init_tx`` then + starts a background transmit thread that runs for the step duration. """ if transmitter.control_method == "external_script": if not transmitter.script: @@ -384,6 +423,20 @@ class CampaignExecutor: elif transmitter.control_method == "sdr": logger.debug("SDR TX not yet implemented — skipping start") + elif transmitter.control_method == "sdr_remote": + ctrl = self._remote_tx_controllers.get(transmitter.id) + if ctrl is None: + raise RuntimeError(f"No remote Tx controller found for transmitter '{transmitter.id}'") + gain = step.power_dbm if step.power_dbm is not None else 0.0 + ctrl.init_tx( + center_frequency=self.config.recorder.center_freq, + sample_rate=self.config.recorder.sample_rate, + gain=gain, + channel=step.channel or 0, + ) + # Start transmission in background; _record() runs concurrently + ctrl.transmit_async(step.duration + 1.0) + else: logger.warning(f"Unknown control method '{transmitter.control_method}' — skipping") @@ -391,6 +444,7 @@ class CampaignExecutor: """Signal the transmitter to stop. Calls ``