diff --git a/ria_toolkit_oss_cli/__init__.py b/ria_toolkit_oss_cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ria_toolkit_oss_cli/cli.py b/ria_toolkit_oss_cli/cli.py new file mode 100644 index 0000000..f15f49f --- /dev/null +++ b/ria_toolkit_oss_cli/cli.py @@ -0,0 +1,20 @@ +""" +This module contains the main group for the utils CLI. +""" + +import click + +from utils_cli.utils import commands + + +@click.group() +@click.option("-v", "--verbose", is_flag=True, type=bool, help="Increase verbosity, especially useful for debugging.") +def cli(verbose): + pass + + +# Loop through project commands, binding them all to the CLI. +for command_name in dir(commands): + command = getattr(commands, command_name) + if isinstance(command, click.Command): + cli.add_command(command, name=command_name) diff --git a/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py b/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py new file mode 100644 index 0000000..debda31 --- /dev/null +++ b/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py @@ -0,0 +1,803 @@ +"""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.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): + if fmt == "sigmf": + if not quiet: + click.echo(f"Saving annotations to: {output_path}.sigmf-meta") + + if output_path: + # Normalize the output path for consistency + return normalize_sigmf_path(output_path) + else: + # Auto-generate from input path + return normalize_sigmf_path(input_path) + else: + if not quiet: + click.echo(f"Saving to: {output_path}") + + if output_path: + return Path(output_path) + else: + # Other formats: add _annotated suffix unless --overwrite + if overwrite: + return input_path + else: + return input_path.with_name(input_path.stem + "_annotated" + input_path.suffix) + + +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 + Subcommands: + list - List annotations + add - Add manual annotation + remove - Remove annotation by index + clear - Clear all annotations + energy - Auto-detect using energy method + cusum - Auto-detect using CUSUM method + threshold - Auto-detect using threshold method + separate - Split annotations by frequency components (Phase 2) + + \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, input, quiet, overwrite) + 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)") + + 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}") + + +# ============================================================================ +# 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=65536, help="FFT size for frequency calculation") +@click.option("--obw-power", type=float, default=0.9999, help="Power percentage for OBW/NBW (0.99-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="signal", 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): + """Split annotations by frequency components (Phase 2). + + 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}") diff --git a/ria_toolkit_oss_cli/ria_toolkit_oss/capture.py b/ria_toolkit_oss_cli/ria_toolkit_oss/capture.py new file mode 100644 index 0000000..f2781e6 --- /dev/null +++ b/ria_toolkit_oss_cli/ria_toolkit_oss/capture.py @@ -0,0 +1,413 @@ +"""Capture command for SDR devices.""" + +import os + +import click + +from utils.io import to_blue, to_npy, to_sigmf, to_wav +from utils.io.recording import generate_filename +from utils.view.view_signal_simple import view_simple_sig + +from .common import ( + echo_progress, + echo_verbose, + format_frequency, + format_sample_rate, + get_sdr_device, + load_yaml_config, + parse_frequency, + parse_metadata_args, +) +from .config import load_user_config +from .discover import ( + find_bladerf_devices, + find_hackrf_devices, + find_pluto_devices, + find_rtlsdr_devices, + find_thinkrf_devices, + find_uhd_devices, + load_sdr_drivers, +) + + +def list_all_devices(): + # Load drivers and collect all devices + load_sdr_drivers(verbose=False) + + all_devices = [] + all_devices.extend(find_uhd_devices()) + all_devices.extend(find_pluto_devices()) + all_devices.extend(find_hackrf_devices()) + all_devices.extend(find_bladerf_devices()) + all_devices.extend(find_rtlsdr_devices()) + all_devices.extend(find_thinkrf_devices()) + + return all_devices + + +def auto_select_device(quiet: bool = False) -> str: + """Auto-select device if only one is connected. + + Args: + quiet: Suppress warning messages + + Returns: + Device type string + + Raises: + click.ClickException: If no devices or multiple devices found + """ + all_devices = list_all_devices() + + if len(all_devices) == 0: + raise click.ClickException("No SDR devices found.\n" "Run 'utils discover' to see available devices.") + + elif len(all_devices) == 1: + device = all_devices[0] + device_type = device.get("type", "Unknown").lower().replace("-", "").replace(" ", "") + + # Map device type names to internal names + type_map = { + "plutosdr": "pluto", + "hackrf": "hackrf", + "hackrfone": "hackrf", + "bladerf": "bladerf", + "usrp": "usrp", + "b200": "usrp", + "b210": "usrp", + "rtlsdr": "rtlsdr", + "thinkrf": "thinkrf", + } + + device_type = type_map.get(device_type, device_type) + + if not quiet: + click.echo( + click.style("Warning: ", fg="yellow") + + f"No device specified. Auto-detected {device.get('type', 'Unknown')}", + err=True, + ) + click.echo(f"Use --device {device_type} to suppress this warning.\n", err=True) + + return device_type + + else: + device_list = "\n".join(f" - {d.get('type', 'Unknown')}" for d in all_devices) + raise click.ClickException( + f"Multiple devices found. Specify with --device\n\n" + f"Available devices:\n{device_list}\n\n" + f"Run 'utils discover' for more details." + ) + + +def get_metadata_dict(config, metadata): + # Parse metadata - start with user config defaults + metadata_dict = config.get("metadata", {}) + + # Load user config and apply defaults + user_config = load_user_config() + + # Apply user config metadata (if user config exists) + if user_config: + # Add standard metadata fields from config + for key in ["author", "organization", "project", "location", "testbed"]: + if key in user_config and key not in metadata_dict: + metadata_dict[key] = user_config[key] + + # Add SigMF fields from config + if "sigmf" in user_config: + sigmf = user_config["sigmf"] + for key in ["license", "hw", "dataset"]: + if key in sigmf and key not in metadata_dict: + metadata_dict[key] = sigmf[key] + + # CLI metadata overrides everything + if metadata: + metadata_dict.update(parse_metadata_args(metadata)) + + return metadata_dict + + +def save_visualization(recording, output_file: str, quiet: bool = False): + """Save visualization of recording. + + Args: + recording: Recording object + output_file: Path to save visualization (PNG) + quiet: Suppress progress messages + """ + # Generate image filename matching recording filename + base_name = os.path.splitext(output_file)[0] + if output_file.endswith(".sigmf-data"): + base_name = output_file.replace(".sigmf-data", "") + output_file = base_name + ".png" + + try: + echo_progress(f"Generating visualization: {output_file}", quiet) + view_simple_sig(recording, output_path=output_file, saveplot=True, fast_mode=False, labels_mode=True) + except ImportError as e: + click.echo(click.style("Warning: ", fg="yellow") + f"Could not save visualization: {e}", err=True) + except Exception as e: + click.echo(click.style("Warning: ", fg="yellow") + f"Failed to save visualization: {e}", err=True) + + +def select_params(device, sample_rate, gain, bandwidth, quiet, verbose): + # Auto-select device if not specified + if device is None: + device = auto_select_device(quiet) + + # Apply device-specific defaults (matching signal-testbed) + if sample_rate is None: + # Sample rate defaults based on signal-testbed hardware limits + device_sample_rates = { + "rtlsdr": 2.4e6, # RTL-SDR max is 3.2 MHz, use 2.4 MHz safe default + "thinkrf": 31.25e6, # ThinkRF decimation 4 (from 125 MS/s) + "pluto": 20e6, # PlutoSDR up to 61 MHz, 20 MHz safe + "hackrf": 20e6, # HackRF up to 20 MHz + "bladerf": 40e6, # BladeRF up to 61 MHz, 40 MHz safe + "usrp": 50e6, # USRP up to 200 MHz, 50 MHz default from signal-testbed + } + sample_rate = device_sample_rates.get(device, 20e6) + + if gain is None: + # RX gain defaults (matching signal-testbed's 32 dB baseline, adjusted per device) + default_gains = { + "pluto": 32, + "hackrf": 32, + "bladerf": 32, + "usrp": 32, + "rtlsdr": 32, # RTL-SDR will auto-select closest valid gain + "thinkrf": 0, # ThinkRF uses attenuation, 0 = no attenuation + } + gain = default_gains.get(device, 32) + echo_verbose(f"Using default RX gain: {gain} dB for {device}", verbose) + + if bandwidth is None: + # Bandwidth defaults (match sample rate for most devices) + device_bandwidths = { + "rtlsdr": None, # RTL-SDR doesn't support bandwidth setting + "thinkrf": None, # ThinkRF manages bandwidth internally + "pluto": sample_rate, + "hackrf": sample_rate, + "bladerf": sample_rate, + "usrp": sample_rate, + } + bandwidth = device_bandwidths.get(device) + + return device, sample_rate, gain, bandwidth + + +def determine_output_format(output, output_format, output_dir): + # Determine output format and save + # If output specified, parse directory and filename + if output: + # Auto-detect format from extension if not specified + if output_format is None: + ext = os.path.splitext(output)[1].lower().lstrip(".") + if ext in ["sigmf", "sigmf-data"]: + output_format = "sigmf" + elif ext == "npy": + output_format = "npy" + elif ext == "wav": + output_format = "wav" + elif ext == "blue": + output_format = "blue" + else: + # Default to SigMF + output_format = "sigmf" + + # Get output directory and filename from provided path + output_path_dir = os.path.dirname(output) + if output_path_dir: + output_dir = output_path_dir + output_filename = os.path.basename(output) + + # Remove extension for formats that add it + if output_format == "sigmf": + output_filename = output_filename.replace(".sigmf-data", "").replace(".sigmf", "") + else: + # Use auto-generated filename based on timestamp and rec_id + output_filename = None # Will be auto-generated by save functions + if output_format is None: + output_format = "sigmf" # Default format + + return output_format, output_filename, output_dir + + +# ============================================================================ +# Main command +# ============================================================================ + +@click.command() +@click.argument("inputs", nargs=-1, required=True, type=click.Path(exists=True)) +@click.argument("output", nargs=1, required=True, type=click.Path()) +@click.option( + "--device", + "-d", + type=click.Choice(["pluto", "hackrf", "bladerf", "usrp", "rtlsdr", "thinkrf"]), + help="Device type", +) +@click.option("--ident", "-i", help="Device identifier (IP address or name=value, e.g., 192.168.2.1 or name=mypluto)") +@click.option( + "--config", "-c", "config_file", type=click.Path(exists=True), help="Load parameters from YAML config file" +) +@click.option( + "--sample-rate", "-s", type=float, default=None, help="Sample rate in Hz (e.g., 2e6) [default: device-specific]" +) +@click.option( + "--center-frequency", + "-f", + type=str, + default="2440M", + show_default=True, + help="Center frequency (e.g., 915e6, 2.4G)", +) +@click.option("--gain", "-g", type=float, help="RX gain in dB [default: device-specific]") +@click.option("--bandwidth", "-b", type=float, help="Bandwidth in Hz (if supported) [default: device-specific]") +@click.option("--num-samples", "-n", type=int, show_default=True, help="Number of samples to capture") +@click.option("--duration", "-t", type=float, help="Duration in seconds (alternative to --num-samples)") +@click.option("--output", "-o", help="Output filename (defaults to auto-generated with timestamp)") +@click.option("--output-dir", default="recordings", help="Output directory (default: recordings/)") +@click.option( + "--format", + "output_format", + type=click.Choice(["npy", "sigmf", "wav", "blue"]), + help="Output format (default: sigmf)", +) +@click.option("--save-image", is_flag=True, help="Save visualization PNG alongside recording") +@click.option("--metadata", "-m", multiple=True, help="Add custom metadata (KEY=VALUE)") +@click.option("--verbose", "-v", is_flag=True, help="Verbose output") +@click.option("--quiet", "-q", is_flag=True, help="Suppress progress output") +def capture( + device, + ident, + config_file, + sample_rate, + center_frequency, + gain, + bandwidth, + num_samples, + duration, + output, + output_dir, + output_format, + save_image, + metadata, + verbose, + quiet, +): + """Capture IQ samples from SDR device and save to file. + + \b + Examples: + utils capture -d hackrf -s 2e6 -f 2.44e6 -b 2e6 + utils capture -d pluto -s 1e6 -f 2e9 -b 2e6 -n 50 + + """ + + # Load config file if specified + config = {} + if config_file: + config = load_yaml_config(config_file) + echo_verbose(f"Loaded config from: {config_file}", verbose) + + # Command-line args override config file + device = device or config.get("device") + ident = ident or config.get("ident") or config.get("serial") # Support legacy 'serial' in config + sample_rate = sample_rate or config.get("sample_rate") + center_frequency = center_frequency or config.get("center_frequency") + gain = gain or config.get("gain") + bandwidth = bandwidth or config.get("bandwidth") + num_samples = num_samples or config.get("num_samples") + duration = duration or config.get("duration") + output = output or config.get("output") + output_format = output_format or config.get("format") + + # Parse metadata + metadata_dict = get_metadata_dict(config=config, metadata=metadata) + + # Select parameters + device, sample_rate, gain, bandwidth = select_params( + device=device, sample_rate=sample_rate, gain=gain, bandwidth=bandwidth, quiet=quiet, verbose=verbose + ) + + # Parse frequency + center_freq_hz = parse_frequency(center_frequency) + + # Calculate num_samples from duration if needed + if duration is not None and num_samples is None: + num_samples = int(duration * sample_rate) + echo_verbose(f"Duration {duration}s = {num_samples} samples at {format_sample_rate(sample_rate)}", verbose) + + # Show capture parameters + echo_progress(f"Capturing from {device.upper()}...", quiet) + echo_progress(f"Sample rate: {format_sample_rate(sample_rate)}", quiet) + echo_progress(f"Center frequency: {format_frequency(center_freq_hz)}", quiet) + if gain is not None: + echo_progress(f"Gain: {gain} dB", quiet) + if bandwidth is not None: + echo_progress(f"Bandwidth: {format_sample_rate(bandwidth)}", quiet) + + # Initialize device + echo_verbose("Initializing device...", verbose) + sdr = get_sdr_device(device, ident) + + try: + # Initialize RX with parameters + echo_verbose("Initializing RX...", verbose) + sdr.init_rx( + sample_rate=sample_rate, center_frequency=center_freq_hz, gain=gain, channel=0 # Default to channel 0 + ) + + # Set bandwidth if supported (after init_rx) + if bandwidth is not None and hasattr(sdr, "set_rx_bandwidth"): + sdr.set_rx_bandwidth(bandwidth) + + # Capture + echo_progress(f"Capturing {num_samples} samples...", quiet) + recording = sdr.record(num_samples=num_samples) + + echo_progress( + f"Captured {recording.data.shape[1] if len(recording.data.shape) > 1 else len(recording.data)} samples", + quiet, + ) + + # Add custom metadata to recording + if metadata_dict: + for key, value in metadata_dict.items(): + recording.update_metadata(key, value) + + output_format, output_filename, output_dir = determine_output_format( + output=output, output_format=output_format, output_dir=output_dir + ) + echo_progress(f"Saving to {output_format.upper()} format...", quiet) + + # Save recording (filenames with timestamp auto-generated if output_filename is None) + # All to_* functions handle directory creation internally + # Note: to_sigmf returns None, others return path + if output_format == "sigmf": + to_sigmf(recording, filename=output_filename, path=output_dir) + # Build path manually since to_sigmf doesn't return it + base_name = ( + os.path.splitext(output_filename)[0] if output_filename else generate_filename(recording=recording) + ) + saved_path = os.path.join(output_dir, f"{base_name}.sigmf-data") + elif output_format == "npy": + saved_path = to_npy(recording, filename=output_filename, path=output_dir) + elif output_format == "wav": + saved_path = to_wav(recording, filename=output_filename, path=output_dir) + elif output_format == "blue": + saved_path = to_blue(recording, filename=output_filename, path=output_dir) + + echo_progress(f"Saved to: {saved_path}", quiet) + + # Save visualization if requested + if save_image: + save_visualization(recording, saved_path, quiet) + + finally: + # Clean up device + echo_verbose("Closing device...", verbose) + sdr.close() + + echo_progress("Capture complete!", quiet) diff --git a/ria_toolkit_oss_cli/ria_toolkit_oss/combine.py b/ria_toolkit_oss_cli/ria_toolkit_oss/combine.py new file mode 100644 index 0000000..ba1a363 --- /dev/null +++ b/ria_toolkit_oss_cli/ria_toolkit_oss/combine.py @@ -0,0 +1,494 @@ +"""Combine command - Combine multiple recordings into a single file.""" + +import copy +import time +from pathlib import Path + +import click +import numpy as np + +from utils.data import Recording +from utils.io import from_npy_legacy, load_recording +from utils_cli.utils.common import ( + echo_progress, + echo_verbose, + format_sample_count, + save_recording, +) + + +def load_recording_list(inputs, legacy, verbose, quiet): + recordings = [] + for input_path in inputs: + input_path = Path(input_path) + + try: + if legacy: + recording = from_npy_legacy(str(input_path)) + else: + recording = load_recording(str(input_path)) + + # Store original filename in metadata if not present + if "original_file" not in recording._metadata: + recording._metadata["original_file"] = input_path.name + + num_samples = recording.data.shape[1] + echo_verbose(f" Loading {input_path.name} ({format_sample_count(num_samples)} samples)... Done", verbose) + recordings.append(recording) + + except Exception as e: + raise click.ClickException(f"Failed to load {input_path}: {e}") + + return recordings + + +def pad(recordings, max_len, verbose): + if verbose: + click.echo(f"Aligning (zero-pad to {format_sample_count(max_len)} samples)...") + aligned = [] + for i, rec in enumerate(recordings): + if rec.data.shape[1] < max_len: + pad_width = max_len - rec.data.shape[1] + padded = np.pad(rec.data, ((0, 0), (0, pad_width)), mode="constant") + if verbose: + click.echo(f" Recording {i+1}: +{format_sample_count(pad_width)} zeros at end") + aligned.append(padded) + else: + aligned.append(rec.data) + return aligned + + +def pad_start(recordings, max_len, pad_start_sample, verbose): + if verbose: + click.echo(f"Aligning (pad-start at sample {format_sample_count(pad_start_sample)})...") + aligned = [] + for i, rec in enumerate(recordings): + if rec.data.shape[1] < max_len: + pad_before = pad_start_sample + pad_after = max_len - rec.data.shape[1] - pad_before + if pad_after < 0: + raise click.ClickException( + f"Invalid --pad-start-sample\n" + f"Start sample {format_sample_count(pad_start_sample)} with recording length " + f"{format_sample_count(rec.data.shape[1])} exceeds max length {format_sample_count(max_len)}" + ) + padded = np.pad(rec.data, ((0, 0), (pad_before, pad_after)), mode="constant") + if verbose: + click.echo( + f" Recording {i+1}: +{format_sample_count(pad_before)} zeros before, " + f"+{format_sample_count(pad_after)} zeros after" + ) + aligned.append(padded) + else: + aligned.append(rec.data) + return aligned + + +def pad_center(recordings, max_len, verbose): + if verbose: + click.echo(f"Aligning (pad-center in {format_sample_count(max_len)} samples)...") + aligned = [] + for i, rec in enumerate(recordings): + if rec.data.shape[1] < max_len: + total_pad = max_len - rec.data.shape[1] + pad_before = total_pad // 2 + pad_after = total_pad - pad_before + padded = np.pad(rec.data, ((0, 0), (pad_before, pad_after)), mode="constant") + if verbose: + click.echo( + f" Recording {i+1}: +{format_sample_count(pad_before)} zeros before, " + f"+{format_sample_count(pad_after)} zeros after" + ) + aligned.append(padded) + else: + aligned.append(rec.data) + return aligned + + +def pad_end(recordings, max_len, verbose): + if verbose: + click.echo(f"Aligning (pad-end, align to {format_sample_count(max_len)} samples)...") + aligned = [] + for i, rec in enumerate(recordings): + if rec.data.shape[1] < max_len: + pad_width = max_len - rec.data.shape[1] + padded = np.pad(rec.data, ((0, 0), (pad_width, 0)), mode="constant") + if verbose: + click.echo(f" Recording {i+1}: +{format_sample_count(pad_width)} zeros at beginning") + aligned.append(padded) + else: + aligned.append(rec.data) + return aligned + + +def repeat(recordings, max_len, verbose): + if verbose: + click.echo(f"Aligning (repeat pattern to match {format_sample_count(max_len)} samples)...") + aligned = [] + for i, rec in enumerate(recordings): + if rec.data.shape[1] < max_len: + n_repeats = int(np.ceil(max_len / rec.data.shape[1])) + repeated = np.tile(rec.data, (1, n_repeats)) + truncated = repeated[:, :max_len] + if verbose: + click.echo( + f" Recording {i+1}: repeated {n_repeats} times, " + f"truncated to {format_sample_count(max_len)} samples" + ) + aligned.append(truncated) + else: + aligned.append(rec.data) + return aligned + + +def repeat_spaced(recordings, max_len, repeat_spacing, verbose): + if repeat_spacing <= 0: + raise click.ClickException("Error: --align-mode repeat-spaced requires --repeat-spacing SAMPLES (must be > 0)") + if verbose: + click.echo(f"Aligning (repeat with {format_sample_count(repeat_spacing)} sample spacing)...") + + aligned = [] + for i, rec in enumerate(recordings): + if rec.data.shape[1] < max_len: + result = np.zeros((rec.data.shape[0], max_len), dtype=rec.data.dtype) + pattern_len = rec.data.shape[1] + pos = 0 + repetitions = 0 + while pos < max_len: + end = min(pos + pattern_len, max_len) + result[:, pos:end] = rec.data[:, : end - pos] + repetitions += 1 + pos = end + repeat_spacing + if verbose: + click.echo( + f" Recording {i+1}: {repetitions} repetitions " + f"({format_sample_count(pattern_len)} samples + {format_sample_count(repeat_spacing)} spacing)" + ) + aligned.append(result) + else: + aligned.append(rec.data) + return aligned + + +def align_for_add(recordings, align_mode, pad_start_sample=0, repeat_spacing=0, verbose=False): + """Align recordings for add mode based on alignment strategy. + + Args: + recordings: List of Recording objects + align_mode: Alignment mode string + pad_start_sample: Sample offset for pad-start mode + repeat_spacing: Spacing between repetitions for repeat-spaced mode + verbose: Verbose output + + Returns: + List of aligned numpy arrays + + Raises: + click.ClickException: If alignment fails or is invalid + """ + lengths = [rec.data.shape[1] for rec in recordings] + max_len = max(lengths) + min_len = min(lengths) + + # All same length, no alignment needed + if len(set(lengths)) == 1: + if verbose: + click.echo(f" All recordings same length ({format_sample_count(max_len)} samples)") + return [rec.data for rec in recordings] + + if align_mode == "error": + raise click.ClickException( + f"Recordings have different lengths: {[format_sample_count(len) for len in lengths]}\n" + f"Use --align-mode to specify alignment strategy:\n" + f" --align-mode truncate (use shortest: {format_sample_count(min_len)} samples)\n" + f" --align-mode pad (zero-pad to longest: {format_sample_count(max_len)} samples)\n" + f" --align-mode pad-center (center shorter in longer)\n" + f" --align-mode pad-end (align end of recordings)\n" + f" --align-mode repeat (repeat shorter to match longest)" + ) + + elif align_mode == "truncate": + if verbose: + click.echo(f"Aligning (truncate to {format_sample_count(min_len)} samples)...") + for i, rec in enumerate(recordings): + if rec.data.shape[1] > min_len: + click.echo(f" Recording {i+1}: truncated from {format_sample_count(rec.data.shape[1])} samples") + return [rec.data[:, :min_len] for rec in recordings] + + elif align_mode == "pad": + return pad(recordings, max_len, verbose) + + elif align_mode == "pad-start": + return pad_start(recordings, max_len, pad_start_sample, verbose) + + elif align_mode == "pad-center": + return pad_center(recordings, max_len, verbose) + + elif align_mode == "pad-end": + return pad_end(recordings, max_len, verbose) + + elif align_mode == "repeat": + return repeat(recordings, max_len, verbose) + + elif align_mode == "repeat-spaced": + return repeat_spaced(recordings, max_len, repeat_spacing, verbose) + + else: + raise click.ClickException(f"Unknown alignment mode: {align_mode}") + + +def concat_recordings(recordings, verbose=False): + """Concatenate recordings end-to-end. + + Args: + recordings: List of Recording objects + verbose: Verbose output + + Returns: + Recording: Combined recording + """ + if verbose: + click.echo("Concatenating...") + + # Concatenate data + combined_data = np.concatenate([r.data for r in recordings], axis=1) + + # Merge annotations with adjusted indices + combined_annotations = [] + offset = 0 + for rec in recordings: + for ann in rec._annotations: + new_ann = copy.deepcopy(ann) + new_ann.sample_start += offset + combined_annotations.append(new_ann) + offset += rec.data.shape[1] + + # Use metadata from first recording + combined_metadata = recordings[0]._metadata.copy() + combined_metadata["combined_from"] = [rec._metadata.get("original_file", "unknown") for rec in recordings] + combined_metadata["combine_mode"] = "concat" + combined_metadata["num_inputs"] = len(recordings) + combined_metadata["combine_timestamp"] = time.time() + + # Create combined recording + result = Recording(data=combined_data, metadata=combined_metadata) + result._annotations = combined_annotations + + if verbose: + click.echo(f"Total: {format_sample_count(combined_data.shape[1])} samples") + + return result + + +def add_recordings(recordings, align_mode="error", pad_start_sample=0, repeat_spacing=0, verbose=False): + """Add/mix recordings sample-by-sample. + + Args: + recordings: List of Recording objects + align_mode: Alignment mode for different-length recordings + pad_start_sample: Sample offset for pad-start mode + repeat_spacing: Spacing for repeat-spaced mode + verbose: Verbose output + + Returns: + Recording: Combined recording + """ + # Align recordings + aligned_data = align_for_add( + recordings, align_mode, pad_start_sample=pad_start_sample, repeat_spacing=repeat_spacing, verbose=verbose + ) + + if verbose: + click.echo("Adding signals...") + + # Add all signals + combined_data = sum(aligned_data) + + # Keep first recording's annotations only + combined_metadata = recordings[0]._metadata.copy() + combined_metadata["combined_from"] = [rec._metadata.get("original_file", "unknown") for rec in recordings] + combined_metadata["combine_mode"] = "add" + combined_metadata["align_mode"] = align_mode + combined_metadata["num_inputs"] = len(recordings) + combined_metadata["combine_timestamp"] = time.time() + + # Warn if other recordings had annotations + if any(len(rec._annotations) > 0 for rec in recordings[1:]): + click.echo("Warning: Only first recording's annotations preserved (others discarded in add mode)", err=True) + + # Create combined recording + result = Recording(data=combined_data, metadata=combined_metadata) + result._annotations = recordings[0]._annotations.copy() + + if verbose: + click.echo(f"Total: {format_sample_count(combined_data.shape[1])} samples") + + return result + + +@click.command() +@click.argument("inputs", nargs=-1, required=True, type=click.Path(exists=True)) +@click.argument("output", nargs=1, required=True, type=click.Path()) +@click.option( + "--mode", + type=click.Choice(["concat", "add"], case_sensitive=False), + default="concat", + help="Combination mode (default: concat)", +) +@click.option( + "--align-mode", + type=click.Choice( + ["error", "truncate", "pad", "pad-start", "pad-center", "pad-end", "repeat", "repeat-spaced"], + case_sensitive=False, + ), + default="error", + help="Add mode alignment strategy (default: error)", +) +@click.option("--pad-start-sample", type=int, default=0, metavar="N", help="Sample offset for pad-start mode") +@click.option( + "--repeat-spacing", + type=int, + default=0, + metavar="SAMPLES", + help="Spacing between repetitions for repeat-spaced mode", +) +@click.option("--legacy", is_flag=True, help="Load inputs as legacy NPY format") +@click.option("--normalize", is_flag=True, help="Normalize after combining") +@click.option( + "--output-format", + type=click.Choice(["sigmf", "npy", "wav", "blue"], case_sensitive=False), + help="Force output format", +) +@click.option("--overwrite", is_flag=True, help="Overwrite existing output file") +@click.option( + "--metadata", multiple=True, metavar="KEY=VALUE", help="Add custom metadata (can be used multiple times)" +) +@click.option("--verbose", is_flag=True, help="Verbose output") +@click.option("--quiet", is_flag=True, help="Suppress output") +def combine( + inputs, + output, + mode, + align_mode, + pad_start_sample, + repeat_spacing, + legacy, + normalize, + output_format, + overwrite, + metadata, + verbose, + quiet, +): + """Combine multiple recordings into a single file. + + \b + INPUTS Input recording files (2 or more) + OUTPUT Output filename + + \b + Modes: + concat Concatenate recordings end-to-end (default) + add Add signals sample-by-sample (mix/superimpose) + + \b + Examples: + # Concatenate recordings + utils combine chunk1.npy chunk2.npy chunk3.npy full.npy + \b + # Add signal and noise + utils combine signal.npy noise.npy noisy.npy --mode add\n + \b + # Add with center alignment + utils combine long.npy short.npy output.npy --mode add --align-mode pad-center\n + \b + # Repeat pattern with spacing + utils combine signal.npy pattern.npy output.npy --mode add --align-mode repeat-spaced --repeat-spacing 10000 + """ + # Validate inputs + if len(inputs) < 2: + raise click.ClickException( + "Error: At least 2 input files required\n" "Usage: utils combine INPUT1 INPUT2 [INPUT3 ...] OUTPUT" + ) + + # Special case: single input (though we require 2+ above, this handles edge case) + if len(inputs) == 1: + echo_progress("Warning: Only one input file specified", quiet) + echo_progress("Nothing to combine. Copying to output...", quiet) + + mode = mode.lower() + align_mode = align_mode.lower() + + # Load recordings + align_str = ", " + align_mode + " alignment" if mode == "add" and align_mode != "error" else "" + echo_progress( + f"Combining {len(inputs)} recordings ({mode} mode{align_str})...", + quiet, + ) + recordings = load_recording_list(inputs, legacy, verbose, quiet) + + # Validate for empty recordings + for i, rec in enumerate(recordings): + if rec.data.shape[1] == 0: + raise click.ClickException( + f"Error: Input file '{inputs[i]}' has 0 samples\n" "Cannot combine empty recordings" + ) + + # Validate for add mode + if mode == "add": + # Check sample rates match + sample_rates = [rec._metadata.get("sample_rate") for rec in recordings] + sample_rates = [sr for sr in sample_rates if sr is not None] + if len(sample_rates) > 1 and len(set(sample_rates)) > 1: + raise click.ClickException( + f"Error: Recordings have different sample rates (add mode)\n" + f"Sample rates: {sample_rates}\n" + "All recordings must have matching sample rates for add mode" + ) + + # Check channel counts match + channel_counts = [rec.data.shape[0] for rec in recordings] + if len(set(channel_counts)) > 1: + raise click.ClickException( + f"Error: Recordings have different channel counts\n" + f"Channels: {channel_counts}\n" + "All recordings must have same number of channels" + ) + + # Combine recordings + if mode == "concat": + combined = concat_recordings(recordings, verbose=verbose) + elif mode == "add": + combined = add_recordings( + recordings, + align_mode=align_mode, + pad_start_sample=pad_start_sample, + repeat_spacing=repeat_spacing, + verbose=verbose, + ) + else: + raise click.ClickException(f"Unknown mode: {mode}") + + # Add custom metadata + for meta_item in metadata: + if "=" not in meta_item: + raise click.ClickException(f"Invalid metadata format: {meta_item} (expected KEY=VALUE)") + key, value = meta_item.split("=", 1) + combined.update_metadata(key, value) + + # Normalize if requested + if normalize: + echo_verbose("Normalizing...", verbose) + combined = combined.normalize() + combined.update_metadata("normalized", True) + + # Save output + try: + save_recording(combined, output, output_format=output_format, overwrite=overwrite, verbose=verbose) + echo_progress(f"Saved to: {output}", quiet) + except Exception as e: + raise click.ClickException(f"Failed to save output: {e}") + + +if __name__ == "__main__": + combine() diff --git a/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py b/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py new file mode 100644 index 0000000..101be50 --- /dev/null +++ b/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py @@ -0,0 +1,26 @@ +# flake8: noqa: F401 +""" +This module contains all the CLI bindings for the utils package. +""" + +from .annotate import annotate +from .capture import capture +from .combine import combine +from .convert import convert + +# Import all command functions +from .discover import discover +from .generate import generate +from .init import init +from .split import split +from .transform import transform +from .transmit import transmit +from .view import view + +# Aliases +synth = generate + +# All commands will be automatically registered by cli.py +# Commands must be click.Command instances + + diff --git a/ria_toolkit_oss_cli/ria_toolkit_oss/common.py b/ria_toolkit_oss_cli/ria_toolkit_oss/common.py new file mode 100644 index 0000000..5b5f61d --- /dev/null +++ b/ria_toolkit_oss_cli/ria_toolkit_oss/common.py @@ -0,0 +1,408 @@ +"""Common utilities for CLI commands.""" + +import os +from pathlib import Path +from typing import Any, Dict, List, Optional + +import click +import yaml + +from ria_toolkit_oss.datatypes.recording import Recording +from src.ria_toolkit_oss.io.recording import to_blue, to_npy, to_sigmf, to_wav + + +def load_yaml_config(config_file: str) -> Dict[str, Any]: + """Load YAML configuration file. + + Args: + config_file: Path to YAML file + + Returns: + Dictionary of configuration parameters + + Raises: + click.ClickException: If file cannot be loaded + """ + try: + with open(config_file, "r") as f: + config = yaml.safe_load(f) + return config or {} + except FileNotFoundError: + raise click.ClickException(f"Config file not found: {config_file}") + except yaml.YAMLError as e: + raise click.ClickException(f"Error parsing YAML config: {e}") + + +def detect_file_format(filepath): + """Detect file format from extension. + + Args: + filepath: Path to file + + Returns: + str: Format name ('sigmf', 'npy', 'wav', 'blue') + + Raises: + click.ClickException: If format cannot be determined + """ + filepath = Path(filepath) + ext = filepath.suffix.lower() + + if ext in [".sigmf", ".sigmf-data", ".sigmf-meta"]: + 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}'\n" f"Supported extensions: .sigmf, .npy, .wav, .blue" + ) + + +def parse_metadata_args(metadata_args: List[str]) -> Dict[str, Any]: + """Parse metadata KEY=VALUE arguments. + + Args: + metadata_args: List of "KEY=VALUE" strings + + Returns: + Dictionary of parsed metadata + + Raises: + click.ClickException: If metadata format is invalid + """ + metadata = {} + for arg in metadata_args: + if "=" not in arg: + raise click.ClickException(f"Invalid metadata format: '{arg}'. Expected KEY=VALUE") + + key, value = arg.split("=", 1) + + if key in ["experiment", "campaign", "project"]: + metadata[key] = value + else: + # Try to parse numeric values + try: + # Try float first (handles both int and float) + if "." in value or "e" in value.lower(): + metadata[key] = float(value) + else: + metadata[key] = int(value) + except ValueError: + # Keep as string + metadata[key] = value + + return metadata + + +def parse_frequency(freq_str: str) -> float: + """Parse frequency string with suffixes (k, M, G). + + Args: + freq_str: Frequency string (e.g., "915e6", "2.4G", "433M") + + Returns: + Frequency in Hz + + Raises: + click.ClickException: If frequency format is invalid + """ + try: + # Handle scientific notation and plain numbers + if "e" in freq_str.lower() or freq_str.replace(".", "").replace("-", "").isdigit(): + return float(freq_str) + + # Handle suffix notation (k, M, G) + multipliers = {"k": 1e3, "K": 1e3, "M": 1e6, "G": 1e9} + + for suffix, mult in multipliers.items(): + if freq_str.endswith(suffix): + return float(freq_str[:-1]) * mult + + # No suffix, try as plain number + return float(freq_str) + + except ValueError: + raise click.ClickException( + f"Invalid frequency format: '{freq_str}'. " "Use formats like: 915e6, 2.4G, 433M, 100k" + ) + + +def format_frequency(freq_hz: float) -> str: + """Format frequency in human-readable form. + + Args: + freq_hz: Frequency in Hz + + Returns: + Formatted string (e.g., "915.0 MHz") + """ + if freq_hz >= 1e9: + return f"{freq_hz/1e9:.2f} GHz" + elif freq_hz >= 1e6: + return f"{freq_hz/1e6:.2f} MHz" + elif freq_hz >= 1e3: + return f"{freq_hz/1e3:.2f} kHz" + else: + return f"{freq_hz:.2f} Hz" + + +def format_sample_rate(rate_hz: float) -> str: + """Format sample rate in human-readable form. + + Args: + rate_hz: Sample rate in Hz + + Returns: + Formatted string (e.g., "2.0 MSPS") + """ + if rate_hz >= 1e6: + return f"{rate_hz/1e6:.2f} MS/s" + elif rate_hz >= 1e3: + return f"{rate_hz/1e3:.2f} kS/s" + else: + return f"{rate_hz:.2f} S/s" + + +def format_sample_count(count): + """Format sample count with thousands separator.""" + return f"{count:,}" + + +def get_output_path(filename: Optional[str], path: Optional[str], default_dir: str = "recordings") -> str: + """Generate full output path. + + Args: + filename: Output filename (can be None for auto-generated) + path: Output directory path + default_dir: Default directory if path not specified + + Returns: + Full path for output file + """ + if path is None: + path = default_dir + + # Create directory if it doesn't exist + if not os.path.exists(path): + os.makedirs(path) + + if filename: + return os.path.join(path, filename) + else: + return path + + +def save_recording(recording: Recording, output_path=None, output_format=None, overwrite=False, verbose=False): + """Save recording to file with format-specific handling. + + Args: + recording: Recording object to save + output_path: Output file path + output_format: Optional format override + overwrite: Whether to overwrite existing files + verbose: Verbose output + + Raises: + click.ClickException: If save fails + """ + if output_path is None: + # Auto-generate filename + timestamp = recording.timestamp + rec_id = recording.rec_id[:8] + signal_type = recording.metadata.get("signal_type", "signal") + output_path = f"{signal_type}_{rec_id}_{int(timestamp)}" + + output_path = Path(output_path) + + # Detect format if not specified + if output_format is None: + output_format = detect_file_format(output_path) + + # For sigmf, strip extension to get base name + if output_format == "sigmf" and output_path.suffix not in [".sigmf-data", ".sigmf-meta", ".sigmf"]: + base_name = output_path.name + else: + base_name = output_path.stem + + output_dir = output_path.parent + + # Create output directory if needed + if output_dir and not output_dir.exists(): + output_dir.mkdir(parents=True, exist_ok=True) + echo_verbose(f"Created directory: {output_dir}", verbose) + + # Check for overwriting + check_for_overwriting(overwrite, output_format, output_path) + + # Save based on format + try: + if output_format == "sigmf": + to_sigmf(recording, filename=base_name, path=str(output_dir), overwrite=overwrite) + elif output_format == "npy": + to_npy(recording, filename=str(output_path), overwrite=overwrite) + elif output_format == "wav": + to_wav(recording, filename=str(output_path), overwrite=overwrite) + elif output_format == "blue": + to_blue(recording, filename=str(output_path), overwrite=overwrite) + else: + raise click.ClickException(f"Unsupported output format: {output_format}") + except Exception as e: + raise click.ClickException(f"Failed to save output: {e}") + + +def echo_verbose(message: str, verbose: bool): + """Print message only in verbose mode. + + Args: + message: Message to print + verbose: Whether verbose mode is enabled + """ + if verbose: + click.echo(message) + + +def echo_progress(message: str, quiet: bool = False): + """Print progress message unless in quiet mode. + + Args: + message: Progress message + quiet: Whether quiet mode is enabled + """ + if not quiet: + click.echo(message, err=True) + + +def confirm_dangerous_operation(message: str, skip_confirm: bool = False) -> bool: + """Ask for confirmation of potentially dangerous operation. + + Args: + message: Warning message + skip_confirm: Skip confirmation (for automation) + + Returns: + True if user confirmed, False otherwise + """ + if skip_confirm: + return True + + click.echo(click.style("WARNING: ", fg="yellow", bold=True) + message, err=True) + return click.confirm("Continue?", default=False) + + +def check_for_overwriting(overwrite, output_format, output_path): + # Check if output exists (unless overwriting) + if not overwrite: + output_path = Path(output_path) + + if output_format == "sigmf": + data_file = output_path.with_suffix(".sigmf-data") + meta_file = output_path.with_suffix(".sigmf-meta") + if data_file.exists() or meta_file.exists(): + raise click.ClickException( + f"Output files exist: {data_file.name}, {meta_file.name}\n" f"Use --overwrite to replace" + ) + elif output_path.exists(): + raise click.ClickException(f"Output file '{output_path}' already exists\n" f"Use --overwrite to replace") + + +def parse_ident(ident: Optional[str]) -> tuple[Optional[str], Optional[str]]: + """ + Parse device identifier into IP address or name. + + Args: + ident: Device identifier (IP address or name=value) + + Returns: + Tuple of (ip_address, name) where one will be None + """ + if not ident: + return None, None + + if "=" in ident: + key, value = ident.split("=", 1) + if key.lower() == "name": + return None, value + else: + return ident, None + else: + return ident, None + + +def get_sdr_device(device_type: str, ident: Optional[str] = None, tx=False): + """ + Get TX-capable SDR device instance. + + Args: + device_type: Type of device (pluto, hackrf, bladerf, usrp) + ident: Device identifier (IP address or name=value) + + Returns: + SDR device instance + + Raises: + click.ClickException: If device cannot be initialized or doesn't support TX + """ + TX_CAPABLE_DEVICES = ["pluto", "hackrf", "bladerf", "usrp"] + if tx and device_type not in TX_CAPABLE_DEVICES: + raise click.ClickException( + f"Device '{device_type}' does not support transmission (RX only)\n" + f"TX-capable devices: {', '.join(TX_CAPABLE_DEVICES)}" + ) + + ip_addr, name = parse_ident(ident) + + try: + if device_type == "pluto": + from utils.sdr.pluto import Pluto + + if ip_addr: + return Pluto(identifier=ip_addr) + else: + return Pluto() + + elif device_type == "hackrf": + from utils.sdr.hackrf import HackRF + + return HackRF() + + elif device_type == "bladerf": + from utils.sdr.blade import Blade + + return Blade() + + elif device_type == "usrp": + from utils.sdr.usrp import USRP + + if ip_addr: + return USRP(identifier=f"addr={ip_addr}") + elif name: + return USRP(identifier=f"name={name}") + else: + return USRP() + + elif device_type == "rtlsdr": + from utils.sdr.rtlsdr import RTLSDR + + return RTLSDR() + + elif device_type == "thinkrf": + from utils.sdr.thinkrf import ThinkRF + + if ip_addr: + return ThinkRF(identifier=ip_addr) + else: + return ThinkRF() + + else: + raise click.ClickException(f"Unknown device type: {device_type}") + + except ImportError as e: + raise click.ClickException( + f"Failed to import {device_type} driver: {e}\n" f"Ensure required dependencies are installed" + ) + except Exception as e: + raise click.ClickException(f"Failed to initialize {device_type}: {e}") diff --git a/ria_toolkit_oss_cli/ria_toolkit_oss/config.py b/ria_toolkit_oss_cli/ria_toolkit_oss/config.py new file mode 100644 index 0000000..976f2c2 --- /dev/null +++ b/ria_toolkit_oss_cli/ria_toolkit_oss/config.py @@ -0,0 +1,206 @@ +"""Configuration file utilities for Utils CLI. + +This module provides utilities for managing the user configuration file. +The core integration (actually using these configs) is TODO for the core team. +""" + +import os +from pathlib import Path +from typing import Optional + +import yaml + + +def get_config_path(config_path: Optional[str] = None) -> Path: + """Get path to user config file. + + Args: + config_path: Optional custom config path + + Returns: + Path to config file + """ + if config_path: + return Path(config_path) + + # Try XDG_CONFIG_HOME first (Linux standard) + xdg_config = os.environ.get("XDG_CONFIG_HOME") + if xdg_config: + return Path(xdg_config) / "utils" / "config.yaml" + + # Fall back to ~/.utils/config.yaml + return Path.home() / ".utils" / "config.yaml" + + +def load_user_config(config_path: Optional[str] = None) -> Optional[dict]: + """Load user configuration from file. + + Args: + config_path: Optional custom config path + + Returns: + Config dict if file exists, None otherwise + """ + path = get_config_path(config_path) + + if not path.exists(): + return None + + try: + with open(path, "r") as f: + config = yaml.safe_load(f) + return config if config else {} + except yaml.YAMLError as e: + raise ValueError(f"Invalid YAML in config file: {e}") + except Exception as e: + raise IOError(f"Error reading config file: {e}") + + +def save_user_config(config: dict, config_path: Optional[str] = None) -> Path: + """Save user configuration to file. + + Args: + config: Configuration dictionary + config_path: Optional custom config path + + Returns: + Path where config was saved + """ + path = get_config_path(config_path) + + # Create parent directory if it doesn't exist + path.parent.mkdir(parents=True, exist_ok=True) + + # Write config + with open(path, "w") as f: + f.write("# Utils SDR CLI Configuration\n") + f.write("# Auto-generated by 'utils init'\n") + f.write("# Edit with 'utils init' or modify this file directly\n\n") + yaml.dump(config, f, default_flow_style=False, sort_keys=False) + + # Set secure permissions (user read/write only) + try: + os.chmod(path, 0o600) + except Exception: + pass # Best effort on Windows + + return path + + +def validate_config(config: dict) -> list[str]: + """Validate configuration and return list of warnings. + + Args: + config: Configuration dictionary + + Returns: + List of warning messages (empty if no issues) + """ + warnings = [] + + # Check for empty author + if not config.get("author"): + warnings.append("Author field is empty - consider setting your name") + + # Check for non-standard license (but allow Proprietary as valid) + if "sigmf" in config and "license" in config["sigmf"]: + license_id = config["sigmf"]["license"] + # Common licenses (Proprietary is valid, not open source) + common_licenses = [ + "Proprietary", + "CC0-1.0", + "CC-BY-4.0", + "CC-BY-SA-4.0", + "MIT", + "Apache-2.0", + "GPL-3.0", + "BSD-3-Clause", + ] + if license_id not in common_licenses: + warnings.append( + f"License '{license_id}' is not a common identifier. " + f"Consider: Proprietary, CC-BY-4.0, MIT, or other SPDX identifier" + ) + + return warnings + + +def format_config_display(config: dict) -> str: + """Format configuration for display. + + Args: + config: Configuration dictionary + + Returns: + Formatted string + """ + lines = [] + + # Main metadata + if config.get("author"): + lines.append(f"Author: {config['author']}") + if config.get("organization"): + lines.append(f"Organization: {config['organization']}") + if config.get("project"): + lines.append(f"Project: {config['project']}") + if config.get("location"): + lines.append(f"Location: {config['location']}") + if config.get("testbed"): + lines.append(f"Testbed: {config['testbed']}") + + # SigMF metadata + if "sigmf" in config: + sigmf = config["sigmf"] + if sigmf.get("license"): + lines.append(f"License: {sigmf['license']}") + if sigmf.get("hw"): + lines.append(f"Hardware: {sigmf['hw']}") + if sigmf.get("dataset"): + lines.append(f"Dataset: {sigmf['dataset']}") + + return "\n".join(lines) if lines else "(empty configuration)" + + +# TODO for core team: Integration functions +# These will be implemented when wiring config into core utils logic + + +def merge_config(user_config: dict, cli_args: dict) -> dict: + """Merge configs with precedence: cli_args > user_config > defaults. + + TODO: Implement this when integrating with capture/convert/transmit commands. + + Args: + user_config: User configuration from file + cli_args: Arguments from CLI + + Returns: + Merged configuration + """ + # Placeholder implementation + merged = user_config.copy() + merged.update({k: v for k, v in cli_args.items() if v is not None}) + return merged + + +def apply_config_to_metadata(metadata: dict, config: dict) -> dict: + """Apply configuration defaults to recording metadata. + + TODO: Implement this in capture.py, convert.py when core team wires it in. + + Args: + metadata: Existing metadata dict + config: User configuration + + Returns: + Updated metadata dict + """ + # Placeholder implementation + updated = metadata.copy() + + # Add config values if not already present + for key in ["author", "organization", "project", "location", "testbed"]: + if key in config and key not in updated: + updated[key] = config[key] + + return updated diff --git a/ria_toolkit_oss_cli/ria_toolkit_oss/convert.py b/ria_toolkit_oss_cli/ria_toolkit_oss/convert.py new file mode 100644 index 0000000..5c196ad --- /dev/null +++ b/ria_toolkit_oss_cli/ria_toolkit_oss/convert.py @@ -0,0 +1,303 @@ +"""Convert command - Convert recordings between file formats.""" + +import os +from pathlib import Path + +import click + +from utils.io.recording import ( + from_npy, + load_recording, + to_blue, + to_npy, + to_sigmf, + to_wav, +) +from utils_cli.utils.common import ( + check_for_overwriting, + detect_file_format, + echo_progress, + echo_verbose, + format_sample_count, +) + +from .config import load_user_config + + +def parse_metadata_override(metadata_str): + """Parse KEY=VALUE metadata string. + + Args: + metadata_str: String in format "key=value" + + Returns: + tuple: (key, value) where value is converted to appropriate type + """ + if "=" not in metadata_str: + raise click.BadParameter(f"Metadata must be in KEY=VALUE format, got: {metadata_str}") + + key, value = metadata_str.split("=", 1) + + # Try to convert to number if possible + try: + # Try int first + if "." not in value: + return (key, int(value)) + else: + return (key, float(value)) + except ValueError: + # Keep as string + return (key, value) + + +@click.command() +@click.argument("input", type=click.Path(exists=True)) +@click.argument("output", type=click.Path(), required=False) +@click.option( + "--format", + "output_format", + type=click.Choice(["npy", "sigmf", "wav", "blue"]), + help="Output format (required if OUTPUT not specified, otherwise auto-detected from extension)", +) +@click.option("--output-dir", type=click.Path(), help="Output directory (default: current directory)") +@click.option("--legacy", is_flag=True, help="Load input as legacy NPY format") +@click.option("--wav-sample-rate", type=float, default=48000, show_default=True, help="Target WAV sample rate in Hz") +@click.option( + "--wav-bits", type=click.Choice(["16", "32"]), default="32", show_default=True, help="WAV bits per sample" +) +@click.option( + "--blue-format", + type=click.Choice(["CI", "CF", "CD"]), + default="CI", + show_default=True, + help="MIDAS Blue format: CI (int16), CF (float32), CD (float64)", +) +@click.option("--overwrite", is_flag=True, help="Overwrite output if it exists") +@click.option("--metadata", multiple=True, help="Add/override metadata as KEY=VALUE (can be repeated)") +@click.option("--verbose", "-v", is_flag=True, help="Verbose output") +@click.option("--quiet", "-q", is_flag=True, help="Suppress output") +def convert( # noqa: C901 + input, + output, + output_format, + output_dir, + legacy, + wav_sample_rate, + wav_bits, + blue_format, + overwrite, + metadata, + verbose, + quiet, +): + """Convert recordings between file formats. + + Automatically detects input format and converts to desired output format. + Supports SigMF, NumPy (.npy), WAV IQ stereo, and MIDAS Blue formats. + + If OUTPUT is not specified, the input filename is used with a new extension + based on the --format option. + + \b + Examples: + # SigMF to NumPy (explicit output) + utils convert recording.sigmf-data output.npy + \b + # Auto-generate output filename + utils convert recording.npy --format sigmf + \b + # Convert to specific directory + utils convert long_path/recording.npy --format sigmf --output-dir converted + \b + # NumPy to WAV with decimation + utils convert high_rate.npy audio.wav --wav-sample-rate 48000 + \b + # Legacy NPY to SigMF + utils convert old.npy --format sigmf --legacy --overwrite + \b + # Add metadata during conversion + utils convert raw.npy --format sigmf --metadata "location=lab" --metadata "antenna=dipole" + """ + + # Generate output filename if not provided + if output is None: + if output_format is None: + raise click.ClickException( + "Either OUTPUT or --format must be specified\n" + "Examples:\n" + " utils convert input.npy output.sigmf\n" + " utils convert input.npy --format sigmf" + ) + + # Get input filename without extension + input_path = Path(input) + input_stem = input_path.stem + + # For SigMF input, remove .sigmf-data or .sigmf-meta suffix + if input_stem.endswith(".sigmf-data") or input_stem.endswith(".sigmf-meta"): + input_stem = input_stem[:-11] # Remove '.sigmf-data'/'.sigmf-meta' + elif input_stem.endswith(".sigmf"): + input_stem = input_stem[:-6] # Remove '.sigmf' + + # Determine output directory + if output_dir: + out_dir = Path(output_dir) + else: + out_dir = Path(".") # Current directory + + # Generate output filename with new extension + extension_map = {"sigmf": ".sigmf", "npy": ".npy", "wav": ".wav", "blue": ".blue"} + output = str(out_dir / f"{input_stem}{extension_map[output_format]}") + + echo_verbose(f"Auto-generated output: {output}", verbose) + + # Detect input and output formats + input_format = detect_file_format(input) + if output_format is None: + output_format = detect_file_format(output) + + # Check for overwriting + output_path = Path(output) + check_for_overwriting(overwrite, output_format, output_path) + + echo_progress(f"Converting: {os.path.basename(input)} → {os.path.basename(output)}", quiet) + echo_progress(f"Input format: {input_format.upper()}", quiet) + echo_progress(f"Output format: {output_format.upper()}", quiet) + + # Load input recording + echo_verbose("Reading input...", verbose) + try: + if legacy: + echo_verbose("Using legacy NPY loader", verbose) + recording = from_npy(input, legacy=True) + else: + recording = load_recording(input) + except Exception as e: + raise click.ClickException(f"Failed to load input file: {e}") + + # Get sample count + if hasattr(recording.data, "shape"): + if len(recording.data.shape) == 2: + num_samples = recording.data.shape[1] + num_channels = recording.data.shape[0] + else: + num_samples = len(recording.data) + num_channels = 1 + else: + num_samples = len(recording.data) + num_channels = 1 + + echo_progress(f"Samples: {format_sample_count(num_samples)}", quiet) + if num_channels > 1: + echo_progress(f"Channels: {num_channels}", quiet) + echo_verbose("Input loaded successfully", verbose) + + # Load user config and apply default metadata + user_config = load_user_config() + if user_config: + echo_verbose("Applying user config metadata...", verbose) + # Add standard metadata fields from config (if not already present) + for key in ["author", "organization", "project", "location", "testbed"]: + if key in user_config and key not in recording.metadata: + recording._metadata[key] = user_config[key] + echo_verbose(f" {key} = {user_config[key]} (from config)", verbose) + + # Add SigMF fields from config (if not already present) + if "sigmf" in user_config: + sigmf = user_config["sigmf"] + for key in ["license", "hw", "dataset"]: + if key in sigmf and key not in recording.metadata: + recording._metadata[key] = sigmf[key] + echo_verbose(f" {key} = {sigmf[key]} (from config)", verbose) + + # Apply metadata overrides from CLI (highest priority) + if metadata: + echo_verbose("Applying metadata overrides from CLI...", verbose) + for meta_str in metadata: + key, value = parse_metadata_override(meta_str) + recording._metadata[key] = value + echo_verbose(f" {key} = {value} (CLI override)", verbose) + + # Convert to output format + echo_verbose(f"Writing {output_format.upper()} output...", verbose) + + # Split output into directory and filename for functions that need it + output_dir = output_path.parent + output_filename = output_path.name + + # If output_dir is empty (relative path with no dir), use current directory + if str(output_dir) == ".": + output_dir = None + elif not output_dir.exists(): + # Create output directory if it doesn't exist + output_dir.mkdir(parents=True, exist_ok=True) + + try: + # Note: All to_* functions use (recording, filename, path) signature + # We split the output path into directory and filename components + if output_format == "sigmf": + to_sigmf(recording, filename=output_filename, path=output_dir, overwrite=overwrite) + echo_progress( + ( + f"Conversion complete: {output_path.with_suffix('.sigmf-data').name}, " + f"{output_path.with_suffix('.sigmf-meta').name}" + ), + quiet, + ) + + elif output_format == "npy": + to_npy(recording, filename=output_filename, path=output_dir, overwrite=overwrite) + echo_progress(f"Conversion complete: {output}", quiet) + + elif output_format == "wav": + # Check for multichannel + if num_channels > 1: + raise click.ClickException( + f"WAV export not supported for multichannel recordings\n" + f"Input has {num_channels} channels, WAV export requires single channel" + ) + + # Show decimation info if applicable + original_sample_rate = recording.metadata.get("sample_rate", wav_sample_rate) + if original_sample_rate > wav_sample_rate: + decimation_factor = int(original_sample_rate / wav_sample_rate) + new_sample_count = num_samples // decimation_factor + echo_progress(f"Original sample rate: {original_sample_rate / 1e6:.1f} MHz", quiet) + echo_progress(f"Target sample rate: {wav_sample_rate / 1e3:.1f} kHz", quiet) + echo_progress(f"Decimation factor: {decimation_factor}", quiet) + echo_progress(f"Output samples: {format_sample_count(new_sample_count)}", quiet) + echo_verbose("Decimating...", verbose) + + to_wav( + recording, + filename=output_filename, + path=output_dir, + target_sample_rate=wav_sample_rate, + bits_per_sample=int(wav_bits), + overwrite=overwrite, + ) + echo_progress(f"Conversion complete: {output}", quiet) + + elif output_format == "blue": + # Convert blue format string to format expected by to_blue + format_map = {"CI": "CI", "CF": "CF", "CD": "CD"} # Complex int16 # Complex float32 # Complex float64 + blue_data_format = format_map[blue_format] + echo_verbose(f"Using MIDAS Blue format: {blue_format} ({blue_data_format})", verbose) + + to_blue( + recording, filename=output_filename, path=output_dir, data_format=blue_data_format, overwrite=overwrite + ) + echo_progress(f"Conversion complete: {output}", quiet) + + except Exception as e: + raise click.ClickException(f"Failed to write output file: {e}") + + # Show metadata preservation info in verbose mode + if verbose and recording.metadata: + echo_verbose("\nMetadata preserved:", verbose) + for key, value in recording.metadata.items(): + echo_verbose(f" {key}: {value}", verbose) + + +if __name__ == "__main__": + convert() diff --git a/ria_toolkit_oss_cli/ria_toolkit_oss/discover.py b/ria_toolkit_oss_cli/ria_toolkit_oss/discover.py new file mode 100644 index 0000000..03e45d3 --- /dev/null +++ b/ria_toolkit_oss_cli/ria_toolkit_oss/discover.py @@ -0,0 +1,518 @@ +"""Device discovery utilities for SDR devices.""" + +import json +import re +import subprocess +from typing import Any, Dict, List, Tuple + +import click + +# Track loaded and failed drivers +_loaded_drivers = [] +_failed_drivers = [] +_failure_reasons = {} + + +def load_sdr_drivers(verbose: bool = False) -> Tuple[List[str], List[str], Dict[str, str]]: + """ + Load available SDR drivers. + + Args: + verbose: Show detailed error messages + + Returns: + Tuple of (loaded_drivers, failed_drivers, failure_reasons) + """ + global _loaded_drivers, _failed_drivers, _failure_reasons # noqa: F824 + + _loaded_drivers.clear() + _failed_drivers.clear() + _failure_reasons.clear() + + # Try to import each SDR driver + drivers = { + "pluto": "utils.sdr.pluto", + "hackrf": "utils.sdr.hackrf", + "bladerf": "utils.sdr.bladerf", + "usrp": "utils.sdr.usrp", + "rtlsdr": "utils.sdr.rtlsdr", + "thinkrf": "utils.sdr.thinkrf", + } + + for driver_name, module_path in drivers.items(): + try: + # Attempt to import the driver module + if not verbose: + # Suppress output for quiet loading + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + __import__(module_path) + else: + __import__(module_path) + + _loaded_drivers.append(driver_name) + + except ImportError as e: + _failed_drivers.append(driver_name) + error_msg = str(e) + if "No module named" in error_msg: + module_name = error_msg.split("'")[1] if "'" in error_msg else "unknown" + _failure_reasons[driver_name] = f"ModuleNotFoundError: {module_name}" + else: + _failure_reasons[driver_name] = f"ImportError: {error_msg}" + except Exception as e: + _failed_drivers.append(driver_name) + _failure_reasons[driver_name] = f"{type(e).__name__}: {str(e)}" + + return _loaded_drivers, _failed_drivers, _failure_reasons + + +def find_hackrf_devices() -> List[Dict[str, Any]]: + """Find HackRF devices using hackrf_info command.""" + devices = [] + try: + result = subprocess.check_output(["hackrf_info"], universal_newlines=True, stderr=subprocess.STDOUT, timeout=5) + + # Parse device info + device = {"type": "HackRF One"} + for line in result.split("\n"): + if "Index: " in line: + if "serial" in device: + devices.append(device) + device = {"type": "HackRF One", "device_index": line.split(":")[1].strip()} + if "Serial number:" in line: + device["serial"] = line.split(":")[1].strip() + elif "Board ID Number:" in line: + device["board_id"] = line.split(":")[1].strip() + elif "Firmware Version:" in line: + device["firmware"] = line.split(":")[1].strip() + + if "serial" in device: + devices.append(device) + + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): + pass + + return devices + + +def find_bladerf_devices() -> List[Dict[str, Any]]: + """Find BladeRF devices using bladeRF-cli command.""" + devices = [] + try: + result = subprocess.check_output( + ["bladeRF-cli", "-p"], universal_newlines=True, stderr=subprocess.STDOUT, timeout=5 + ) + + # Parse device info + device = {"type": "BladeRF"} + for line in result.strip().split("\n"): + line = line.strip() + if ":" in line: + key, value = line.split(":", 1) + device[key.strip()] = value.strip() + + if device: + devices.append(device) + + except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired): + pass + + return devices + + +def find_uhd_devices() -> List[Dict[str, Any]]: + """Find USRP/UHD devices using uhd_find_devices command.""" + devices = [] + try: + result = subprocess.check_output( + ["uhd_find_devices"], universal_newlines=True, stderr=subprocess.STDOUT, timeout=10 + ) + + # Parse device blocks + if "-- UHD Device" in result: + device_blocks = result.split("-- UHD Device")[1:] + + for block in device_blocks: + device = {} + lines = block.strip().split("\n") + + for line in lines: + line = line.strip() + if ":" in line and not line.startswith("--"): + key, value = line.split(":", 1) + device[key.strip()] = value.strip() + + if device: + devices.append(device) + + except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired): + pass + + return devices + + +def find_rtlsdr_devices() -> List[Dict[str, Any]]: + """Find RTL-SDR devices using rtl_test command.""" + devices = [] + try: + result = subprocess.check_output( + ["rtl_test", "-t"], universal_newlines=True, stderr=subprocess.STDOUT, timeout=5 + ) + + # Parse device count + for line in result.split("\n"): + if "Found" in line and "device" in line: + match = re.search(r"Found (\d+) device", line) + if match: + count = int(match.group(1)) + elif "SN: " in line: + device_match = re.search(r"(\d+): .*SN: (\w+)", line) + if device_match: + devices.append( + {"type": "RTL-SDR", "device_index": device_match.group(1), "serial": device_match.group(2)} + ) + + if "count" in locals() and len(devices) != count: + raise ValueError("Number of stated devices does not match number of found devices") + + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): + pass + + return devices + + +def ping_ip(ip: str, timeout: int = 1) -> bool: + """ + Ping an IP address to check if device is reachable. + + Args: + ip: IP address to ping + timeout: Timeout in seconds + + Returns: + True if ping successful, False otherwise + """ + try: + subprocess.check_output( + ["ping", "-c", "1", "-W", str(timeout), ip], stderr=subprocess.STDOUT, timeout=timeout + 1 + ) + return True + except (subprocess.CalledProcessError, subprocess.TimeoutExpired): + return False + + +def find_pluto_network() -> List[Dict[str, Any]]: + """Find PlutoSDR devices on the network by pinging common addresses.""" + devices = [] + network_candidates = ["pluto.local", "192.168.2.1", "192.168.3.1"] + + for addr in network_candidates: + if ping_ip(addr, timeout=1): + devices.append( + { + "type": "PlutoSDR", + "uri": f"ip:{addr}", + "description": "Network PlutoSDR", + } + ) + + return devices + + +def find_pluto_devices() -> List[Dict[str, Any]]: + """Find PlutoSDR devices using pyadi-iio.""" + devices = [] + try: + import iio + + contexts = iio.scan_contexts() + + for uri, description in contexts.items(): + if "PlutoSDR" in description or "pluto" in uri.lower(): + try: + ctx = iio.Context(uri) + device_info = { + "type": "PlutoSDR", + "uri": uri, + "serial": ctx.attrs.get("hw_serial", "unknown"), + "firmware": ctx.attrs.get("fw_version", "unknown"), + "ip_addr": ctx.attrs.get("ip,ip-addr", "unknown"), + "model": ctx.attrs.get("hw_model", "unknown"), + "description": description, + } + + unique = True + for existing_device in devices: + if existing_device["serial"] == device_info["serial"]: + unique = False + + if unique: + devices.append(device_info) + ctx._destroy() + except Exception: + pass + + except ImportError: + # Fallback to network ping discovery if pyadi-iio not available + devices.extend(find_pluto_network()) + + if not devices: + usb_devices = get_usb_devices() + pluto_usb = [d for d in usb_devices if "PlutoSDR" in d.get("sdr_type", "")] + for pluto in pluto_usb: + pluto["type"] = "PlutoSDR" + pluto["uri"] = "usb:" + pluto["bus"] + devices.append(pluto) + + return devices + + +def find_thinkrf_devices() -> List[Dict[str, Any]]: + """Find ThinkRF devices (placeholder for future implementation).""" + # ThinkRF uses network-based discovery with proprietary SDK + # TODO: Implement when pyrf is available and working + return [] + + +def get_usb_devices() -> List[Dict[str, Any]]: + """Get USB devices using lsusb for SDR identification.""" + sdr_devices = [] + sdr_ids = { + "2cf0:5250": "BladeRF 2.0", + "2cf0:5246": "BladeRF 1.0", + "0bda:2838": "RTL-SDR", + "0456:b673": "PlutoSDR (ADALM-PLUTO)", + "2500:0020": "USRP B210", + "2500:0021": "USRP B200", + "1d50:604b": "HackRF One", + } + + try: + result = subprocess.check_output(["lsusb"], universal_newlines=True, timeout=5) + + for line in result.strip().split("\n"): + for vid_pid, device_name in sdr_ids.items(): + if vid_pid in line: + match = re.match(r"Bus (\d+) Device (\d+): ID ([0-9a-f:]+) (.+)", line) + if match: + bus, device, usb_id, description = match.groups() + sdr_devices.append( + { + "bus": bus, + "device": device, + "usb_id": usb_id, + "description": description, + "sdr_type": device_name, + } + ) + + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): + pass + + return sdr_devices + + +def discover_all_devices(verbose: bool = False, json_output: bool = False) -> int: + """ + Discover all SDR devices with signal-testbed style output. + + Args: + verbose: Show detailed error messages + + Returns: + A dictionary containing information + """ + load_sdr_drivers(verbose=verbose) + + uhd_devices = find_uhd_devices() + pluto_devices = find_pluto_devices() + rtlsdr_devices = find_rtlsdr_devices() + bladerf_devices = find_bladerf_devices() + hackrf_devices = find_hackrf_devices() + + # Collect all device info + all_devices = [] + all_devices.extend(uhd_devices) + all_devices.extend(pluto_devices) + all_devices.extend(rtlsdr_devices) + all_devices.extend(bladerf_devices) + all_devices.extend(hackrf_devices) + + output = { + "loaded_drivers": _loaded_drivers, + "failed_drivers": _failed_drivers, + "devices": all_devices, + "total_devices": len(all_devices), + } + + if verbose: + output["failure_reasons"] = _failure_reasons + + if not json_output: + output["uhd_devices"] = uhd_devices + output["pluto_devices"] = pluto_devices + output["rtlsdr_devices"] = rtlsdr_devices + output["bladerf_devices"] = bladerf_devices + output["hackrf_devices"] = hackrf_devices + + return output + + +def print_all_devices(device_dict: dict, verbose: bool = False) -> int: # noqa: C901 + """ + Print all SDR devices with signal-testbed style output. + + Args: + device_dict: Dictionary containing all device info + verbose: Show detailed error messages + + Returns: + Total number of devices found + """ + total_devices = 0 + + # USRP/UHD Discovery - Try command-line tool even if driver failed to load + uhd_devices = device_dict["uhd_devices"] + if uhd_devices: + click.echo(f"\n📡 USRP/UHD devices ({len(uhd_devices)}):") + for device in uhd_devices: + name = device.get("name", "Unknown") + product = device.get("product", "Unknown") + serial = device.get("serial", "Unknown") + click.echo(f" ✅ {name} ({product}) - Serial: {serial}") + total_devices += len(uhd_devices) + else: + if verbose: + click.echo("\n📡 USRP/UHD devices: None found") + + # PlutoSDR Discovery - Try both pyadi-iio and USB detection + pluto_devices = device_dict["pluto_devices"] + pluto_count = len(pluto_devices) + + if pluto_count > 0: + click.echo(f"\n📱 PlutoSDR devices ({pluto_count}):") + for device in pluto_devices: + # Determine if network or USB based on URI + uri = device["uri"] + if uri.startswith("ip:"): + click.echo(f" ✅ Network: {uri.replace('ip:', '')}") + elif uri.startswith("usb:"): + click.echo(f" ✅ USB: {device['description']} (Bus {uri.replace('usb:', '').split('.')[0]})") + else: + click.echo(f" ✅ {uri}") + + total_devices += pluto_count + else: + if verbose: + click.echo("\n📱 PlutoSDR devices: None found") + + # RTL-SDR Discovery + if "rtlsdr" in _loaded_drivers: + rtl_devices = device_dict["rtlsdr_devices"] + if rtl_devices: + click.echo(f"\n📻 RTL-SDR devices ({len(rtl_devices)}):") + for device in rtl_devices: + idx = device.get("device_index", 0) + click.echo(f" ✅ Device {idx}: {device.get('type', 'RTL-SDR')}") + total_devices += len(rtl_devices) + else: + if verbose: + click.echo("\n📻 RTL-SDR devices: None found") + + # BladeRF Discovery + if "bladerf" in _loaded_drivers: + bladerf_devices = device_dict["bladerf_devices"] + if bladerf_devices: + click.echo(f"\n⚡ BladeRF devices ({len(bladerf_devices)}):") + for device in bladerf_devices: + desc = device.get("Description", "BladeRF") + serial = device.get("Serial", "Unknown") + click.echo(f" ✅ {desc} - Serial: {serial}") + total_devices += len(bladerf_devices) + else: + if verbose: + click.echo("\n⚡ BladeRF devices: None found") + + # HackRF Discovery + if "hackrf" in _loaded_drivers: + hackrf_devices = device_dict["hackrf_devices"] + if hackrf_devices: + click.echo(f"\n🔧 HackRF devices ({len(hackrf_devices)}):") + for device in hackrf_devices: + serial = device.get("serial", "Unknown") + board = device.get("board_id", "") + firmware = device.get("firmware", "") + info = f"Serial: {serial}" + if board: + info += f" - Board ID: {board}" + if firmware: + info += f" - FW: {firmware}" + click.echo(f" ✅ {device.get('type', 'HackRF')} - {info}") + total_devices += len(hackrf_devices) + else: + if verbose: + click.echo("\n🔧 HackRF devices: None found") + + # ThinkRF Discovery + if "thinkrf" in _loaded_drivers: + if verbose: + click.echo("\n🌐 ThinkRF devices: Discovery not yet implemented") + + return total_devices + + +@click.command(help="Discover connected SDR devices") +@click.option("--verbose", "-v", is_flag=True, help="Show detailed information and errors") +@click.option("--json-output", is_flag=True, help="Output in JSON format") +def discover(verbose, json_output): + """Discover connected SDR devices with driver loading.""" + + device_dict = discover_all_devices(verbose=verbose, json_output=json_output) + + # JSON mode: Load drivers and return structured data + if json_output: + click.echo(json.dumps(device_dict, indent=2)) + return + + # Human-readable mode: Signal-testbed style + + # Print loaded drivers + if _loaded_drivers: + click.echo(f"\n✅ Loaded drivers ({len(_loaded_drivers)}):") + for driver in _loaded_drivers: + click.echo(f" {driver}") + else: + click.echo("\n❌ No drivers loaded successfully") + + # Print failed drivers + if _failed_drivers: + click.echo(f"\n❌ Failed drivers ({len(_failed_drivers)}):") + for driver in _failed_drivers: + if verbose and driver in _failure_reasons: + click.echo(f" {driver}: {_failure_reasons[driver]}") + else: + click.echo(f" {driver}") + + if not verbose and _failed_drivers: + click.echo("\nRun with --verbose to see failure reasons") + + # Device discovery + click.echo("\n" + "=" * 40) + click.echo("Attached Devices") + click.echo("=" * 40) + + total_devices = print_all_devices(device_dict=device_dict, verbose=verbose) + + # Summary + click.echo("\n" + "=" * 40) + click.echo("Discovery Summary") + click.echo("=" * 40) + click.echo(f"Loaded drivers: {len(_loaded_drivers)}") + click.echo(f"Failed drivers: {len(_failed_drivers)}") + click.echo(f"Detected devices: {total_devices}") + + if total_devices == 0: + click.echo("\n💡 No devices detected - ensure they are connected and powered on") diff --git a/ria_toolkit_oss_cli/ria_toolkit_oss/generate.py b/ria_toolkit_oss_cli/ria_toolkit_oss/generate.py new file mode 100644 index 0000000..9a10d0f --- /dev/null +++ b/ria_toolkit_oss_cli/ria_toolkit_oss/generate.py @@ -0,0 +1,1586 @@ +"""Generate command - Generate synthetic signals.""" + +from pathlib import Path +from typing import Optional + +import click +import numpy as np +import yaml + +import utils.signal.basic_signal_generator as basic_gen +from utils.data import Recording +from utils.signal.block_gen.continuous_modulation.fsk_modulator import FSKModulator +from utils.signal.block_generator.basic import FrequencyShift +from utils.signal.block_generator.data_types import DataType +from utils.signal.block_generator.mapping.apsk_mapper import _APSKMapper +from utils.signal.block_generator.mapping.cross_qam_mapper import _CrossQAMMapper +from utils.signal.block_generator.mapping.mapper import Mapper +from utils.signal.block_generator.modulation import ( + GMSKModulator, + OOKModulator, + OQPSKModulator, +) +from utils.signal.block_generator.pulse_shaping import ( + RaisedCosineFilter, + RootRaisedCosineFilter, + Upsampling, +) +from utils.signal.block_generator.source import ( + LFMJammingSource, + RandomBinarySource, + RecordingSource, + SawtoothSource, + SquareSource, +) + +# Block Generator Imports +from utils.signal.block_generator.source_block import SourceBlock + +# Transforms for impairments +from utils.transforms.iq_channel_models import ( + complex_multipath_rayleigh_channel, + rician_fading_channel, +) +from utils.transforms.iq_impairments import ( + add_compression, + add_doppler, + add_gain_fluctuation, + add_phase_noise, + iq_imbalance, +) + +# NR 5G Import +try: + from utils.signal.block_gen.nr_5g.nr_5g_generator import NR5GGenerator + + HAS_NR5G = True +except ImportError: + HAS_NR5G = False + +from utils_cli.utils.common import ( + echo_progress, + echo_verbose, + format_frequency, + format_sample_rate, + parse_metadata_args, + save_recording, +) +from utils_cli.utils.config import load_user_config + + +# Extend Mapper to support new types +def _create_extended_mapper(self): + if self.constellation_type.upper() == "APSK": + return _APSKMapper(self.num_bits_per_symbol, self.normalize, self.use_gray_code) + elif self.constellation_type.upper() == "CROSS_QAM": + return _CrossQAMMapper(self.num_bits_per_symbol, self.normalize, self.use_gray_code) + else: + # Original factory + return self._original_create_constellation_mapper() + + +# Monkey patch Mapper to support new types without modifying original file +Mapper._original_create_constellation_mapper = Mapper._create_constellation_mapper +Mapper._create_constellation_mapper = _create_extended_mapper + + +def load_config_options(ctx, param, value): + """Callback to load options from YAML config file.""" + if not value: + return None + + try: + with open(value, "r") as f: + config = yaml.safe_load(f) + + # Store config in context for other commands to access + ctx.default_map = config + return value + except Exception as e: + raise click.BadParameter(f"Error loading config file: {e}") + + +def apply_user_config_metadata(metadata_tuple): + """Apply user config metadata and merge with CLI metadata. + + Args: + metadata_tuple: Tuple of metadata KEY=VALUE strings from CLI + + Returns: + dict: Merged metadata dictionary + """ + # Load user config + user_config = load_user_config() + metadata_dict = {} + + # Apply user config metadata (if user config exists) + if user_config: + # Add standard metadata fields from config + for key in ["author", "organization", "project", "location", "testbed"]: + if key in user_config: + metadata_dict[key] = user_config[key] + + # Add SigMF fields from config + if "sigmf" in user_config: + sigmf = user_config["sigmf"] + for key in ["license", "hw", "dataset"]: + if key in sigmf: + metadata_dict[key] = sigmf[key] + + # CLI metadata overrides everything + if metadata_tuple: + metadata_dict.update(parse_metadata_args(metadata_tuple)) + + return metadata_dict + + +def get_output_format(output: Optional[str], format_opt: Optional[str]) -> str: + """Determine output format from filename or option.""" + if format_opt: + return format_opt + + if not output: + return "sigmf" # Default to sigmf for better metadata support + + ext = Path(output).suffix.lower() + if ext in [".sigmf", ".sigmf-data", ".sigmf-meta"]: + return "sigmf" + elif ext == ".npy": + return "npy" + elif ext == ".wav": + return "wav" + elif ext == ".blue": + return "blue" + else: + return "sigmf" + + +class FileSourceBlock(SourceBlock): + """Generates bits from a file or bytes.""" + + def __init__(self, data: bytes, repeat: bool = True): + self.data = data + self.repeat = repeat + # Convert to bits + bits = np.unpackbits(np.frombuffer(data, dtype=np.uint8)) + self.bits = bits.astype(np.float32) # SourceBlock expects float32 bits (0.0, 1.0) + self.idx = 0 + + @property + def output_type(self) -> DataType: + return DataType.BITS + + def __call__(self, num_samples: int) -> np.ndarray: + out = np.zeros(num_samples, dtype=np.float32) + filled = 0 + while filled < num_samples: + remaining = num_samples - filled + available = len(self.bits) - self.idx + + take = min(remaining, available) + out[filled : filled + take] = self.bits[self.idx : self.idx + take] + + self.idx += take + filled += take + + if self.idx >= len(self.bits): + if self.repeat: + self.idx = 0 + else: + # Pad with zeros if not repeating + break + + return out + + +def apply_post_processing( + recording: Recording, frequency_shift: float, channel_type: str, channel_params: dict, verbose: bool +) -> Recording: + """Apply frequency shift and channel models to a recording.""" + + # 1. Frequency Shift (Pre-channel) + if frequency_shift != 0: + echo_verbose(f"Applying frequency shift: {format_frequency(frequency_shift)}", verbose) + # Use simple phase shift if only 1 block? No, basic gen FrequencyShift + # We can use RecordingSource + FrequencyShift + record() + source = RecordingSource(recording) + fs_block = FrequencyShift(shift_frequency=frequency_shift, sampling_rate=recording.sample_rate) + fs_block.input = [source] + num = len(recording.data[0]) if recording.n_chan > 0 else len(recording.data) + # get_samples + processed = fs_block.get_samples(num) + recording = Recording(data=processed, metadata=recording.metadata) + + # 2. Dynamic Impairments (Transforms) + + # Rician / Rayleigh + if channel_type == "rayleigh": + # Use improved complex multipath if available + echo_verbose("Applying Multipath Rayleigh Channel", verbose) + recording = complex_multipath_rayleigh_channel( + recording, + num_paths=channel_params.get("multipath_paths") or 3, + max_delay=channel_params.get("multipath_max_delay") or 2.6e-6, + sample_rate=recording.sample_rate, + snr_db=None, # We handle noise separately + ) + + elif channel_type == "rician": + echo_verbose(f"Applying Rician Channel (K={channel_params.get('rician_k', 2.0)})", verbose) + recording = rician_fading_channel( + recording, + k_factor=channel_params.get("rician_k", 2.0), + num_paths=channel_params.get("multipath_paths") or 3, + max_delay=channel_params.get("multipath_max_delay") or 1.2e-6, + sample_rate=recording.sample_rate, + snr_db=None, + ) + + # Doppler + doppler_freq = channel_params.get("doppler_freq") + if doppler_freq: + echo_verbose(f"Applying Doppler (Shift={doppler_freq} Hz)", verbose) + # add_doppler expects velocity. Convert freq to velocity assuming 1GHz carrier or pass freq directly? + # dynamic_channel wrapper handles this conversion. + # Or use add_doppler directly if we have velocity. + # User supplied doppler_freq. + # Let's use a simple transform or dynamic_channel + # We need to reuse dynamic_channel logic for freq->velocity conversion or assume carrier. + # Or create add_doppler_freq(signal, freq_shift) + # add_doppler takes satellite_velocity etc. + # dynamic_channel takes doppler_hz. + # We use dynamic_channel logic here but just for Doppler part + c_light = 299792458 + f_carrier = 1e9 # Assumption for conversion + velocity = doppler_freq * c_light / f_carrier + recording = add_doppler( + recording, + satellite_velocity=velocity, + satellite_initial_distance=1000, + frequency=f_carrier, + sample_rate=recording.sample_rate, + ) + + # IQ Imbalance + amp = channel_params.get("iq_amp_imbalance") + phase = channel_params.get("iq_phase_imbalance") + dc = channel_params.get("iq_dc_offset") + if amp or phase or dc: + echo_verbose(f"Applying IQ Imbalance (Amp={amp}dB, Phase={phase}rad, DC={dc})", verbose) + recording = iq_imbalance( + recording, + amplitude_imbalance=( + amp if amp is not None else 0 + ), # iq_imbalance defaults to 1.5? We want 0 if not set but one of others is set. + phase_imbalance=phase if phase is not None else 0, + dc_offset=dc if dc is not None else 0, + ) + + # Phase Noise + pn = channel_params.get("phase_noise") + if pn: + echo_verbose(f"Applying Phase Noise (Var={pn})", verbose) + recording = add_phase_noise(recording, phase_variance=pn) + + # Gain Fluctuation + gf = channel_params.get("gain_fluctuation") + if gf: + echo_verbose(f"Applying Gain Fluctuation (Var={gf})", verbose) + recording = add_gain_fluctuation(recording, amplitude_variance=gf) + + # Compression + comp = channel_params.get("compression") + if comp: + echo_verbose(f"Applying Compression (Gain={comp})", verbose) + recording = add_compression(recording, compression_gain=comp) + + # 3. AWGN (Final stage usually) + if channel_type == "awgn" or channel_params.get("noise_power"): + # If 'awgn' selected OR noise_power explicitly set (default is 0.1, so always set?) + # If channel_type is NOT awgn/rayleigh/rician, and noise_power is default 0.1? + # If user didn't specify noise_power, but did specify channel_type=none, do we add noise? + # Default noise_power is 0.1. + # If channel_type == 'none', we probably shouldn't add noise unless user asked for it. + # But noise_power has default. + # Let's check if channel_type is 'awgn'. + # Or if user provided --noise-power? + # (We can't distinguish default vs user provided easily with click unless we use ctx) + # For now: only add noise if channel_type is set to something, or if noise_power > 0 and user intended it. + # Simpler: If channel_type == 'awgn', definitely add. + # If rayleigh/rician, they might want noise too. + # If 'none', skip noise? + + should_add_noise = False + if channel_type in ["awgn", "rayleigh", "rician"]: + should_add_noise = True + + if should_add_noise: + npow = channel_params.get("noise_power", 0.1) + echo_verbose(f"Applying AWGN (Power={npow})", verbose) + # Convert Power (variance) to SNR? + # add_awgn_to_signal takes SNR. + # AWGNChannel block takes Variance. + # Use AWGNChannel block logic (additive noise with variance) + # or utils.transforms.iq_channel_models.awgn_channel which takes SNR. + # The user CLI says --noise-power (variance). + # We should use a simple additive noise function with variance. + # transforms.iq_augmentations.generate_awgn uses SNR. + # Let's implement simple additive noise here or use AWGNChannel block. + + # Use AWGNChannel block logic directly + noise_std = np.sqrt(npow / 2) + noise = noise_std * (np.random.randn(*recording.data.shape) + 1j * np.random.randn(*recording.data.shape)) + recording = Recording(data=recording.data + noise, metadata=recording.metadata) + + return recording + + +@click.group() +def generate(): + """Generate synthetic signals. + + \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 + + """ + pass + + +def common_options(f): + """Decorator for common options.""" + f = click.option("--sample-rate", "-s", type=float, required=True, help="Sample rate in Hz")(f) + f = click.option("--num-samples", "-n", type=int, help="Number of samples")(f) + f = click.option("--duration", "-t", type=float, help="Duration in seconds (alternative to --num-samples)")(f) + f = click.option("--frequency-shift", type=float, default=0.0, help="Digital frequency shift from baseband (Hz)")( + f + ) + f = click.option("--center-frequency", "-fc", type=float, help="Metadata center frequency (Hz)")(f) + f = click.option( + "--channel-type", type=click.Choice(["none", "awgn", "rayleigh"]), default="none", help="Channel model" + )(f) + f = click.option("--noise-power", type=float, default=0.1, help="Noise power (variance) for AWGN")(f) + f = click.option("--path-gain", type=float, default=0.0, help="Path gain (dB) for Rayleigh")(f) + f = click.option("--output", "-o", required=True, help="Output filename")(f) + f = click.option("--format", "-F", type=click.Choice(["npy", "sigmf", "wav", "blue"]), help="Output format")(f) + + # Impairment options + f = click.option("--rician-k", type=float, help="Rician K-factor")(f) + f = click.option("--multipath-paths", type=int, help="Multipath: Number of paths")(f) + f = click.option("--multipath-max-delay", type=float, help="Multipath: Max delay (s)")(f) + f = click.option("--doppler-freq", type=float, help="Doppler: Frequency shift (Hz)")(f) + f = click.option("--iq-amp-imbalance", type=float, help="IQ Imbalance: Amplitude (dB)")(f) + f = click.option("--iq-phase-imbalance", type=float, help="IQ Imbalance: Phase (rad)")(f) + f = click.option("--iq-dc-offset", type=float, help="IQ Imbalance: DC Offset")(f) + f = click.option("--phase-noise", type=float, help="Phase Noise: Variance")(f) + f = click.option("--gain-fluctuation", type=float, help="Gain Fluctuation: Variance")(f) + f = click.option("--compression", type=float, help="Compression: Gain")(f) + + f = click.option( + "--config", + "-c", + callback=load_config_options, + is_eager=True, + expose_value=False, + type=click.Path(exists=True), + help="Load parameters from YAML", + )(f) + f = click.option("--overwrite", "-w", is_flag=True, help="Overwrite existing file")(f) + f = click.option("--metadata", "-m", multiple=True, help="Add metadata KEY=VALUE")(f) + f = click.option("--verbose", "-v", is_flag=True, help="Verbose output")(f) + f = click.option("--quiet", "-q", is_flag=True, help="Suppress output")(f) + return f + + +def resolve_length(sample_rate, num_samples, duration, symbols=None, sps=None): + """Resolve generation length.""" + if symbols is not None and sps is not None: + # Modulation specific + if num_samples: + # If both provided, check consistency or prefer num_samples? + # We'll treat symbols as the driver if provided. + pass + return int(symbols * sps) + + if num_samples: + return int(num_samples) + + if duration: + return int(duration * sample_rate) + + # Default + return 10000 + + +@generate.command() +@click.option("--frequency", "-f", type=float, default=1000.0, help="Tone frequency relative to carrier (Hz)") +@click.option("--amplitude", "-a", type=float, default=1.0, help="Amplitude (0.0-1.0)") +@click.option("--phase", "-p", type=float, default=0.0, help="Initial phase in radians") +@common_options +def tone( + sample_rate, + num_samples, + duration, + frequency_shift, + center_frequency, + channel_type, + noise_power, + path_gain, + output, + format, + overwrite, + metadata, + verbose, + quiet, + frequency, + amplitude, + phase, + **kwargs, +): + """Generate a complex tone.""" + + ns = resolve_length(sample_rate, num_samples, duration) + + echo_progress(f"Generating tone: {format_frequency(frequency)} at {format_sample_rate(sample_rate)}", quiet) + + # Use basic_gen for core tone + recording = basic_gen.sine( + sample_rate=int(sample_rate), length=ns, frequency=frequency, amplitude=amplitude, baseband_phase=phase + ) + + if center_frequency: + recording._metadata["center_frequency"] = center_frequency + echo_verbose(f"Center Frequency: {format_frequency(center_frequency)}", verbose) + + # Post processing + chan_params = {"noise_power": noise_power, "path_gain": path_gain} + recording = apply_post_processing(recording, frequency_shift, channel_type, chan_params, verbose) + + # User metadata + metadata = apply_user_config_metadata(metadata) + metadata["signal_type"] = "tone" + for key, value in metadata.items(): + recording.update_metadata(key, value) + + fmt = get_output_format(output, format) + save_recording(recording, output, fmt, overwrite, verbose) + + +@generate.command() +@click.option("--noise-type", "-T", type=click.Choice(["gaussian", "uniform"]), default="gaussian", help="Noise type") +@click.option("--power", "-p", type=float, default=1.0, help="Signal power/variance") +@common_options +def noise( + sample_rate, + num_samples, + duration, + frequency_shift, + center_frequency, + channel_type, + noise_power, + path_gain, + output, + format, + overwrite, + metadata, + verbose, + quiet, + noise_type, + power, + **kwargs, +): + """Generate random noise.""" + + ns = resolve_length(sample_rate, num_samples, duration) + echo_progress(f"Generating {noise_type} noise...", quiet) + + if noise_type == "gaussian": + # AWGN + rms = np.sqrt(power) + recording = basic_gen.noise(sample_rate=int(sample_rate), length=ns, rms_power=rms) + else: + # Uniform + real = np.random.uniform(-1, 1, ns) + imag = np.random.uniform(-1, 1, ns) + a = np.sqrt(3 * power / 2) + data = a * (real + 1j * imag) + recording = Recording(data=data, metadata={"sample_rate": sample_rate}) + + recording._metadata["signal_type"] = "noise" + recording._metadata["noise_type"] = noise_type + + if center_frequency: + recording._metadata["center_frequency"] = center_frequency + + # Post processing + chan_params = {"noise_power": noise_power, "path_gain": path_gain} + recording = apply_post_processing(recording, frequency_shift, channel_type, chan_params, verbose) + + for key, value in apply_user_config_metadata(metadata).items(): + recording.update_metadata(key, value) + fmt = get_output_format(output, format) + save_recording(recording, output, fmt, overwrite, verbose) + + +@generate.command() +@click.option("--bandwidth", "-b", type=float, required=True, help="Chirp bandwidth (Hz)") +@click.option("--period", "-p", type=float, required=True, help="Chirp period (seconds)") +@click.option("--type", "chirp_type", type=click.Choice(["up", "down", "up_down"]), default="up", help="Chirp type") +@common_options +def chirp( + sample_rate, + num_samples, + duration, + frequency_shift, + center_frequency, + channel_type, + noise_power, + path_gain, + output, + format, + overwrite, + metadata, + verbose, + quiet, + bandwidth, + period, + chirp_type, + **kwargs, +): + """Generate LFM Chirp signal.""" + + ns = resolve_length(sample_rate, num_samples, duration) + + echo_progress(f"Generating {chirp_type} chirp ({format_frequency(bandwidth)}, {period}s)...", quiet) + + source = LFMJammingSource(sample_rate=sample_rate, bandwidth=bandwidth, chirp_period=period, chirp_type=chirp_type) + + recording = source.record(ns) + + recording._metadata["signal_type"] = "chirp" + recording._metadata["chirp_type"] = chirp_type + recording._metadata["bandwidth"] = bandwidth + recording._metadata["period"] = period + + if center_frequency: + recording._metadata["center_frequency"] = center_frequency + + # Post processing + chan_params = {"noise_power": noise_power, "path_gain": path_gain} + recording = apply_post_processing(recording, frequency_shift, channel_type, chan_params, verbose) + + for key, value in apply_user_config_metadata(metadata).items(): + recording.update_metadata(key, value) + fmt = get_output_format(output, format) + save_recording(recording, output, fmt, overwrite, verbose) + + +@generate.command() +@click.option("--frequency", "-f", type=float, default=1000.0, help="Frequency (Hz)") +@click.option("--amplitude", "-a", type=float, default=1.0, help="Amplitude") +@click.option("--duty-cycle", "-d", type=float, default=0.5, help="Duty cycle (0.0-1.0)") +@click.option("--phase", "-p", type=float, default=0.0, help="Phase shift (radians)") +@common_options +def square( + sample_rate, + num_samples, + duration, + frequency_shift, + center_frequency, + channel_type, + noise_power, + path_gain, + output, + format, + overwrite, + metadata, + verbose, + quiet, + frequency, + amplitude, + duty_cycle, + phase, + **kwargs, +): + """Generate Square wave.""" + + ns = resolve_length(sample_rate, num_samples, duration) + + echo_progress(f"Generating square wave: {format_frequency(frequency)}...", quiet) + + source = SquareSource( + frequency=frequency, sample_rate=sample_rate, amplitude=amplitude, duty_cycle=duty_cycle, phase_shift=phase + ) + + recording = source.record(ns) + + recording._metadata["signal_type"] = "square" + if center_frequency: + recording._metadata["center_frequency"] = center_frequency + + chan_params = {"noise_power": noise_power, "path_gain": path_gain} + recording = apply_post_processing(recording, frequency_shift, channel_type, chan_params, verbose) + + for key, value in apply_user_config_metadata(metadata).items(): + recording.update_metadata(key, value) + fmt = get_output_format(output, format) + save_recording(recording, output, fmt, overwrite, verbose) + + +@generate.command() +@click.option("--frequency", "-f", type=float, default=1000.0, help="Frequency (Hz)") +@click.option("--amplitude", "-a", type=float, default=1.0, help="Amplitude") +@click.option("--phase", "-p", type=float, default=0.0, help="Phase shift (radians)") +@common_options +def sawtooth( + sample_rate, + num_samples, + duration, + frequency_shift, + center_frequency, + channel_type, + noise_power, + path_gain, + output, + format, + overwrite, + metadata, + verbose, + quiet, + frequency, + amplitude, + phase, + **kwargs, +): + """Generate Sawtooth wave.""" + + ns = resolve_length(sample_rate, num_samples, duration) + + echo_progress(f"Generating sawtooth wave: {format_frequency(frequency)}...", quiet) + + source = SawtoothSource(frequency=frequency, sample_rate=sample_rate, amplitude=amplitude, phase_shift=phase) + + recording = source.record(ns) + + recording._metadata["signal_type"] = "sawtooth" + if center_frequency: + recording._metadata["center_frequency"] = center_frequency + + chan_params = {"noise_power": noise_power, "path_gain": path_gain} + recording = apply_post_processing(recording, frequency_shift, channel_type, chan_params, verbose) + + for key, value in apply_user_config_metadata(metadata).items(): + recording.update_metadata(key, value) + fmt = get_output_format(output, format) + save_recording(recording, output, fmt, overwrite, verbose) + + +def load_source(message_source, message_content, num_bits=None): + if num_bits is not None: + if message_source == "random": + return RandomBinarySource()((1, num_bits)) + elif message_source == "string": + if not message_content: + raise click.BadParameter("Message content required for string source") + return FileSourceBlock(message_content.encode("utf-8"), repeat=True)(num_bits).reshape(1, -1) + + elif message_source == "file": + if not message_content: + raise click.BadParameter("File path required for file source") + + p = Path(message_content) + if not p.exists(): + raise click.BadParameter(f"File not found: {p}") + + return FileSourceBlock(p.read_bytes(), repeat=True)(num_bits).reshape(1, -1) + else: + if message_source == "random": + return RandomBinarySource() # Infinite source + + elif message_source == "string": + if not message_content: + raise click.BadParameter("Message content required for string source") + return FileSourceBlock(message_content.encode("utf-8"), repeat=True) + + elif message_source == "file": + if not message_content: + raise click.BadParameter("File path required for file source") + + p = Path(message_content) + if not p.exists(): + raise click.BadParameter(f"File not found: {p}") + + return FileSourceBlock(p.read_bytes(), repeat=True) + + +def _run_mod_gen( + mod_type, + sample_rate, + symbols, + num_samples, + duration, + order, + symbol_rate, + filter_type, + filter_span, + filter_beta, + message_source, + message_content, + frequency_shift, + center_frequency, + channel_type, + noise_power, + path_gain, + output, + format, + overwrite, + metadata, + verbose, + quiet, +): + + # Resolve length + # If symbols provided, it drives. + # If not, use num_samples/duration to calculate symbols + + if symbol_rate is None: + # Try to infer? No, required. + raise click.BadParameter("Symbol rate required") + + sps = sample_rate / symbol_rate + if not sps.is_integer(): + sps_int = int(round(sps)) + if sps_int < 1: + sps_int = 1 + actual_sr = sps_int * symbol_rate + echo_progress(f"Warning: Non-integer samples per symbol ({sps:.4f}). Rounding to {sps_int}.", quiet) + echo_progress(f"Actual sample rate will be {format_sample_rate(actual_sr)}", quiet) + sps = int(sps_int) + sample_rate = actual_sr + else: + sps = int(sps) + + if symbols is None: + # Calc from duration/samples + ns = resolve_length(sample_rate, num_samples, duration) + symbols = int(np.ceil(ns / sps)) + + echo_progress(f"Generating {mod_type}-{order} ({symbols} symbols)...", quiet) + echo_verbose(f" Sample Rate: {format_sample_rate(sample_rate)} (SPS={sps})", verbose) + + bps = int(np.log2(order)) + total_samples = symbols * sps + + # Source + source = load_source(message_source, message_content, None) + + # Mapper and Pulse Shaping + mapper = Mapper(constellation_type=mod_type, num_bits_per_symbol=bps) + upsampler = Upsampling(factor=sps) + + # Filter + if filter_type == "rrc": + filter_block = RootRaisedCosineFilter(span_in_symbols=filter_span, upsampling_factor=sps, beta=filter_beta) + elif filter_type == "rc": + filter_block = RaisedCosineFilter(span_in_symbols=filter_span, upsampling_factor=sps, beta=filter_beta) + elif filter_type == "gaussian": + raise click.ClickException("Gaussian filter not supported yet") + else: + filter_block = None + + # Generate base signal + mapper.connect_input([source]) + upsampler.connect_input([mapper]) + if filter_block: + filter_block.connect_input([upsampler]) + base_recording = filter_block.record(total_samples) + else: + base_recording = upsampler.record(total_samples) + + # Update metadata + for key, value in { + "modulation": mod_type, + "order": order, + "symbol_rate": symbol_rate, + "symbols": symbols, + "filter": filter_type, + }.items(): + base_recording.update_metadata(key, value) + + if center_frequency: + base_recording.update_metadata("center_frequency", center_frequency) + + # Post Processing + chan_params = {"noise_power": noise_power, "path_gain": path_gain} + final_recording = apply_post_processing(base_recording, frequency_shift, channel_type, chan_params, verbose) + + # Trim if explicit num_samples was requested and we generated more (due to symbol alignment) + target_ns = resolve_length(sample_rate, num_samples, duration) + if target_ns and len(final_recording.data[0]) > target_ns: + # Only trim if difference is significant? + # User usually wants exact length if specified. + if num_samples or duration: # If explicitly asked for length + final_recording = final_recording.trim(target_ns) + + for key, value in apply_user_config_metadata(metadata).items(): + final_recording.update_metadata(key, value) + fmt = get_output_format(output, format) + save_recording(final_recording, output, fmt, overwrite, verbose) + + +@generate.command() +@click.option("--symbols", "-N", type=int, help="Number of symbols") +@click.option("--order", "-M", type=int, required=True, help="QAM Order (4, 16, 32, 64, 128, 256, 1024)") +@click.option("--symbol-rate", "-r", type=float, required=True, help="Symbol rate in Hz") +@click.option( + "--filter", + "filter_type", + type=click.Choice(["rrc", "rc", "gaussian", "none"]), + default="rrc", + help="Pulse shaping filter", +) +@click.option("--filter-span", type=int, default=6, help="Filter span in symbols") +@click.option("--filter-beta", type=float, default=0.35, help="Filter roll-off factor") +@click.option( + "--message-source", type=click.Choice(["random", "file", "string"]), default="random", help="Data source" +) +@click.option("--message-content", help="File path or string content") +@common_options +def qam( + sample_rate, + num_samples, + duration, + frequency_shift, + center_frequency, + channel_type, + noise_power, + path_gain, + output, + format, + overwrite, + metadata, + verbose, + quiet, + rician_k, + multipath_paths, + multipath_max_delay, + doppler_freq, + iq_amp_imbalance, + iq_phase_imbalance, + iq_dc_offset, + phase_noise, + gain_fluctuation, + compression, + symbols, + order, + symbol_rate, + filter_type, + filter_span, + filter_beta, + message_source, + message_content, + **kwargs, +): + """Generate QAM modulated signal.""" + + # Determine modulation type (Normal QAM vs Cross QAM) + if order in [32, 128]: + mod_type = "CROSS_QAM" + else: + mod_type = "QAM" + + _run_mod_gen( + mod_type, + sample_rate, + symbols, + num_samples, + duration, + order, + symbol_rate, + filter_type, + filter_span, + filter_beta, + message_source, + message_content, + frequency_shift, + center_frequency, + channel_type, + noise_power, + path_gain, + output, + format, + overwrite, + metadata, + verbose, + quiet, + ) + + +@generate.command() +@click.option("--symbols", "-N", type=int, help="Number of symbols") +@click.option("--order", "-M", type=int, required=True, help="APSK Order (16, 32, 64, 128, 256)") +@click.option("--symbol-rate", "-r", type=float, required=True, help="Symbol rate in Hz") +@click.option( + "--filter", + "filter_type", + type=click.Choice(["rrc", "rc", "gaussian", "none"]), + default="rrc", + help="Pulse shaping filter", +) +@click.option("--filter-span", type=int, default=6, help="Filter span in symbols") +@click.option("--filter-beta", type=float, default=0.35, help="Filter roll-off factor") +@click.option( + "--message-source", type=click.Choice(["random", "file", "string"]), default="random", help="Data source" +) +@click.option("--message-content", help="File path or string content") +@common_options +def apsk( + sample_rate, + num_samples, + duration, + frequency_shift, + center_frequency, + channel_type, + noise_power, + path_gain, + output, + format, + overwrite, + metadata, + verbose, + quiet, + rician_k, + multipath_paths, + multipath_max_delay, + doppler_freq, + iq_amp_imbalance, + iq_phase_imbalance, + iq_dc_offset, + phase_noise, + gain_fluctuation, + compression, + symbols, + order, + symbol_rate, + filter_type, + filter_span, + filter_beta, + message_source, + message_content, + **kwargs, +): + """Generate APSK modulated signal.""" + _run_mod_gen( + "APSK", + sample_rate, + symbols, + num_samples, + duration, + order, + symbol_rate, + filter_type, + filter_span, + filter_beta, + message_source, + message_content, + frequency_shift, + center_frequency, + channel_type, + noise_power, + path_gain, + output, + format, + overwrite, + metadata, + verbose, + quiet, + ) + + +@generate.command() +@click.option("--symbols", "-N", type=int, help="Number of symbols") +@click.option("--order", "-M", type=int, required=True, help="PAM Order (4, 8, 16)") +@click.option("--symbol-rate", "-r", type=float, required=True, help="Symbol rate in Hz") +@click.option( + "--filter", + "filter_type", + type=click.Choice(["rrc", "rc", "gaussian", "none"]), + default="rrc", + help="Pulse shaping filter", +) +@click.option("--filter-span", type=int, default=6, help="Filter span in symbols") +@click.option("--filter-beta", type=float, default=0.35, help="Filter roll-off factor") +@click.option( + "--message-source", type=click.Choice(["random", "file", "string"]), default="random", help="Data source" +) +@click.option("--message-content", help="File path or string content") +@common_options +def pam( + sample_rate, + num_samples, + duration, + frequency_shift, + center_frequency, + channel_type, + noise_power, + path_gain, + output, + format, + overwrite, + metadata, + verbose, + quiet, + rician_k, + multipath_paths, + multipath_max_delay, + doppler_freq, + iq_amp_imbalance, + iq_phase_imbalance, + iq_dc_offset, + phase_noise, + gain_fluctuation, + compression, + symbols, + order, + symbol_rate, + filter_type, + filter_span, + filter_beta, + message_source, + message_content, + **kwargs, +): + """Generate PAM modulated signal.""" + _run_mod_gen( + "PAM", + sample_rate, + symbols, + num_samples, + duration, + order, + symbol_rate, + filter_type, + filter_span, + filter_beta, + message_source, + message_content, + frequency_shift, + center_frequency, + channel_type, + noise_power, + path_gain, + output, + format, + overwrite, + metadata, + verbose, + quiet, + ) + + +@generate.command() +@click.option("--symbols", "-N", type=int, help="Number of symbols") +@click.option("--order", "-M", type=int, default=2, help="FSK Order (2, 4, 8)") +@click.option("--symbol-rate", "-r", type=float, required=True, help="Symbol rate in Hz") +@click.option("--freq-spacing", type=float, help="Frequency spacing (Hz)") +@click.option("--modulation-index", "-h", type=float, help="Modulation Index (alternative to spacing)") +@click.option( + "--message-source", type=click.Choice(["random", "file", "string"]), default="random", help="Data source" +) +@click.option("--message-content", help="File path or string content") +@common_options +def fsk( + sample_rate, + num_samples, + duration, + frequency_shift, + center_frequency, + channel_type, + noise_power, + path_gain, + output, + format, + overwrite, + metadata, + verbose, + quiet, + rician_k, + multipath_paths, + multipath_max_delay, + doppler_freq, + iq_amp_imbalance, + iq_phase_imbalance, + iq_dc_offset, + phase_noise, + gain_fluctuation, + compression, + symbols, + order, + symbol_rate, + freq_spacing, + modulation_index, + message_source, + message_content, + **kwargs, +): + """Generate FSK modulated signal.""" + + # FSK uses FSKModulator which is a standalone Source/Modulator block? No, it's a Modulator. + # Takes bits input. + + # Determine spacing + if freq_spacing is None: + if modulation_index is None: + modulation_index = 1.0 # Default + freq_spacing = modulation_index * symbol_rate + + # Samples per symbol + sps = sample_rate / symbol_rate # FSKModulator takes sampling_freq and symbol_duration (1/rate) + symbol_duration = 1.0 / symbol_rate + + # Resolve length + ns = resolve_length(sample_rate, num_samples, duration, symbols, sps) + if symbols is None: + symbols = int(np.ceil(ns / sps)) + + echo_progress(f"Generating {order}-FSK (Spacing={format_frequency(freq_spacing)})...", quiet) + + # Bits + bps = int(np.log2(order)) + num_bits = symbols * bps + + # Source + source_bits = load_source(message_source, message_content, num_bits) + + # Modulator + mod = FSKModulator( + num_bits_per_symbol=bps, + frequency_spacing=freq_spacing, + symbol_duration=symbol_duration, + sampling_frequency=sample_rate, + ) + + # Generate + samples = mod(source_bits) + # Flatten + samples = samples.flatten()[:ns] + + recording = Recording(data=samples, metadata={"sample_rate": sample_rate}) + recording._metadata.update( + { + "modulation": "FSK", + "order": order, + "symbol_rate": symbol_rate, + "freq_spacing": freq_spacing, + "mod_index": modulation_index if modulation_index else freq_spacing / symbol_rate, + } + ) + + if center_frequency: + recording._metadata["center_frequency"] = center_frequency + + chan_params = { + "noise_power": noise_power, + "path_gain": path_gain, + "rician_k": rician_k, + "multipath_paths": multipath_paths, + "multipath_max_delay": multipath_max_delay, + "doppler_freq": doppler_freq, + "iq_amp_imbalance": iq_amp_imbalance, + "iq_phase_imbalance": iq_phase_imbalance, + "iq_dc_offset": iq_dc_offset, + "phase_noise": phase_noise, + "gain_fluctuation": gain_fluctuation, + "compression": compression, + } + + recording = apply_post_processing(recording, frequency_shift, channel_type, chan_params, verbose) + + for key, value in apply_user_config_metadata(metadata).items(): + recording.update_metadata(key, value) + fmt = get_output_format(output, format) + save_recording(recording, output, fmt, overwrite, verbose) + + +@generate.command() +@click.option("--symbol-rate", "-r", type=float, required=True, help="Symbol rate in Hz") +@click.option( + "--message-source", type=click.Choice(["random", "file", "string"]), default="random", help="Data source" +) +@click.option("--message-content", help="File path or string content") +@common_options +def ook( + sample_rate, + num_samples, + duration, + frequency_shift, + center_frequency, + channel_type, + noise_power, + path_gain, + output, + format, + overwrite, + metadata, + verbose, + quiet, + rician_k, + multipath_paths, + multipath_max_delay, + doppler_freq, + iq_amp_imbalance, + iq_phase_imbalance, + iq_dc_offset, + phase_noise, + gain_fluctuation, + compression, + symbol_rate, + message_source, + message_content, + **kwargs, +): + """Generate On-Off Keying (OOK) signal.""" + + sps = int(sample_rate / symbol_rate) + ns = resolve_length(sample_rate, num_samples, duration) + + echo_progress("Generating OOK...", quiet) + + # Source Block + source = load_source(message_source, message_content, None) + + # OOK Modulator + mod = OOKModulator(source, samples_per_symbol=sps) + recording = mod.record(ns) + recording._metadata["sample_rate"] = sample_rate + recording._metadata["modulation"] = "OOK" + + if center_frequency: + recording._metadata["center_frequency"] = center_frequency + + chan_params = { + "noise_power": noise_power, + "path_gain": path_gain, + "rician_k": rician_k, + "multipath_paths": multipath_paths, + "multipath_max_delay": multipath_max_delay, + "doppler_freq": doppler_freq, + "iq_amp_imbalance": iq_amp_imbalance, + "iq_phase_imbalance": iq_phase_imbalance, + "iq_dc_offset": iq_dc_offset, + "phase_noise": phase_noise, + "gain_fluctuation": gain_fluctuation, + "compression": compression, + } + + recording = apply_post_processing(recording, frequency_shift, channel_type, chan_params, verbose) + + for key, value in apply_user_config_metadata(metadata).items(): + recording.update_metadata(key, value) + fmt = get_output_format(output, format) + save_recording(recording, output, fmt, overwrite, verbose) + + +@generate.command() +@click.option("--symbol-rate", "-r", type=float, required=True, help="Symbol rate in Hz") +@click.option( + "--message-source", type=click.Choice(["random", "file", "string"]), default="random", help="Data source" +) +@click.option("--message-content", help="File path or string content") +@common_options +def oqpsk( + sample_rate, + num_samples, + duration, + frequency_shift, + center_frequency, + channel_type, + noise_power, + path_gain, + output, + format, + overwrite, + metadata, + verbose, + quiet, + rician_k, + multipath_paths, + multipath_max_delay, + doppler_freq, + iq_amp_imbalance, + iq_phase_imbalance, + iq_dc_offset, + phase_noise, + gain_fluctuation, + compression, + symbol_rate, + message_source, + message_content, + **kwargs, +): + """Generate Offset QPSK (OQPSK) signal.""" + + sps = int(sample_rate / symbol_rate) + ns = resolve_length(sample_rate, num_samples, duration) + + echo_progress("Generating OQPSK...", quiet) + + # Source Block + source = load_source(message_source, message_content, None) + + # OQPSK Modulator + mod = OQPSKModulator(source, samples_per_symbol=sps) + recording = mod.record(ns) + recording._metadata["sample_rate"] = sample_rate + recording._metadata["modulation"] = "OQPSK" + + if center_frequency: + recording._metadata["center_frequency"] = center_frequency + + chan_params = { + "noise_power": noise_power, + "path_gain": path_gain, + "rician_k": rician_k, + "multipath_paths": multipath_paths, + "multipath_max_delay": multipath_max_delay, + "doppler_freq": doppler_freq, + "iq_amp_imbalance": iq_amp_imbalance, + "iq_phase_imbalance": iq_phase_imbalance, + "iq_dc_offset": iq_dc_offset, + "phase_noise": phase_noise, + "gain_fluctuation": gain_fluctuation, + "compression": compression, + } + + recording = apply_post_processing(recording, frequency_shift, channel_type, chan_params, verbose) + + for key, value in apply_user_config_metadata(metadata).items(): + recording.update_metadata(key, value) + fmt = get_output_format(output, format) + save_recording(recording, output, fmt, overwrite, verbose) + + +@generate.command() +@click.option("--symbol-rate", "-r", type=float, required=True, help="Symbol rate in Hz") +@click.option("--bt", type=float, default=0.3, help="Bandwidth-Time product (e.g., 0.3, 0.5)") +@click.option( + "--message-source", type=click.Choice(["random", "file", "string"]), default="random", help="Data source" +) +@click.option("--message-content", help="File path or string content") +@common_options +def gmsk( + sample_rate, + num_samples, + duration, + frequency_shift, + center_frequency, + channel_type, + noise_power, + path_gain, + output, + format, + overwrite, + metadata, + verbose, + quiet, + rician_k, + multipath_paths, + multipath_max_delay, + doppler_freq, + iq_amp_imbalance, + iq_phase_imbalance, + iq_dc_offset, + phase_noise, + gain_fluctuation, + compression, + symbol_rate, + bt, + message_source, + message_content, + **kwargs, +): + """Generate GMSK modulated signal.""" + + sps = int(sample_rate / symbol_rate) + ns = resolve_length(sample_rate, num_samples, duration) + + echo_progress(f"Generating GMSK (BT={bt})...", quiet) + + # Source Block + source = load_source(message_source, message_content, None) + + # GMSK Modulator + mod = GMSKModulator(source, samples_per_symbol=sps, bt=bt) + recording = mod.record(ns) + recording._metadata["sample_rate"] = sample_rate + recording._metadata["modulation"] = "GMSK" + recording._metadata["bt_product"] = bt + + if center_frequency: + recording._metadata["center_frequency"] = center_frequency + + chan_params = { + "noise_power": noise_power, + "path_gain": path_gain, + "rician_k": rician_k, + "multipath_paths": multipath_paths, + "multipath_max_delay": multipath_max_delay, + "doppler_freq": doppler_freq, + "iq_amp_imbalance": iq_amp_imbalance, + "iq_phase_imbalance": iq_phase_imbalance, + "iq_dc_offset": iq_dc_offset, + "phase_noise": phase_noise, + "gain_fluctuation": gain_fluctuation, + "compression": compression, + } + + recording = apply_post_processing(recording, frequency_shift, channel_type, chan_params, verbose) + + for key, value in apply_user_config_metadata(metadata).items(): + recording.update_metadata(key, value) + fmt = get_output_format(output, format) + save_recording(recording, output, fmt, overwrite, verbose) + + +@generate.command() +@click.option("--symbols", "-N", type=int, help="Number of symbols") +@click.option("--order", "-M", type=int, required=True, help="PSK Order (2, 4, 8)") +@click.option("--symbol-rate", "-r", type=float, required=True, help="Symbol rate in Hz") +@click.option( + "--filter", + "filter_type", + type=click.Choice(["rrc", "rc", "gaussian", "none"]), + default="rrc", + help="Pulse shaping filter", +) +@click.option("--filter-span", type=int, default=6, help="Filter span in symbols") +@click.option("--filter-beta", type=float, default=0.35, help="Filter roll-off factor") +@click.option( + "--message-source", type=click.Choice(["random", "file", "string"]), default="random", help="Data source" +) +@click.option("--message-content", help="File path or string content") +@common_options +def psk( + sample_rate, + num_samples, + duration, + frequency_shift, + center_frequency, + channel_type, + noise_power, + path_gain, + output, + format, + overwrite, + metadata, + verbose, + quiet, + symbols, + order, + symbol_rate, + filter_type, + filter_span, + filter_beta, + message_source, + message_content, + **kwargs, +): + """Generate PSK modulated signal.""" + _run_mod_gen( + "PSK", + sample_rate, + symbols, + num_samples, + duration, + order, + symbol_rate, + filter_type, + filter_span, + filter_beta, + message_source, + message_content, + frequency_shift, + center_frequency, + channel_type, + noise_power, + path_gain, + output, + format, + overwrite, + metadata, + verbose, + quiet, + ) + + +@generate.command() +@click.option("--bandwidth", "-b", type=int, required=True, help="Bandwidth in MHz (e.g. 10, 20)") +@click.option("--mu", "-u", type=int, default=1, help="Numerology (0-3)") +@click.option("--frames", type=int, default=1, help="Number of 10ms frames") +@click.option("--ssb/--no-ssb", default=True, help="Enable SSB") +@common_options +def nr5g( + sample_rate, + frequency_shift, + center_frequency, + channel_type, + noise_power, + path_gain, + output, + format, + overwrite, + metadata, + verbose, + quiet, + bandwidth, + mu, + frames, + ssb, + **kwargs, +): + """Generate 5G NR frame.""" + + if not HAS_NR5G: + raise click.ClickException("5G NR Generator not available (missing dependencies or module)") + + echo_progress(f"Generating 5G NR ({bandwidth} MHz, mu={mu}, {frames} frames)...", quiet) + + # NR5GGenerator parameters + # It determines sample rate based on bandwidth/mu/fr? + # nr_ofdm_params(bandwidth_mhz, mu, fr) returns fs. + # We should verify if user supplied sample_rate matches or we should ignore user sample_rate? + # Or we resample? + # The generator has fixed fs for a given BW/mu config usually. + # Let's instantiate it and see its fs. + + gen = NR5GGenerator(bandwidth_mhz=bandwidth, mu=mu, frames_per_recording=frames, ssb=ssb) + + native_fs = gen.fs + if sample_rate and abs(sample_rate - native_fs) > 1.0: + echo_progress( + message=( + f"Warning: Requested sample rate {format_sample_rate(sample_rate)} " + f"differs from native NR rate {format_sample_rate(native_fs)}." + ), + quiet=quiet, + ) + echo_progress("Output will be at native rate.", quiet) + # If we really wanted to support arbitrary rate, we'd need resampling. + # For now, just warn and use native. + + recording = gen.record(batch_size=1) + + recording._metadata["signal_type"] = "nr5g" + + if center_frequency: + recording._metadata["center_frequency"] = center_frequency + + # Post processing + chan_params = {"noise_power": noise_power, "path_gain": path_gain} + recording = apply_post_processing(recording, frequency_shift, channel_type, chan_params, verbose) + + for key, value in apply_user_config_metadata(metadata).items(): + recording.update_metadata(key, value) + fmt = get_output_format(output, format) + save_recording(recording, output, fmt, overwrite, verbose) diff --git a/ria_toolkit_oss_cli/ria_toolkit_oss/init.py b/ria_toolkit_oss_cli/ria_toolkit_oss/init.py new file mode 100644 index 0000000..2ae4c5e --- /dev/null +++ b/ria_toolkit_oss_cli/ria_toolkit_oss/init.py @@ -0,0 +1,318 @@ +"""Init command - Initialize user configuration.""" + +import click + +from .config import ( + format_config_display, + get_config_path, + load_user_config, + save_user_config, + validate_config, +) + + +def prompt_with_default(text: str, default: str = "") -> str: + """Prompt user with optional default value. + + Args: + text: Prompt text + default: Default value + + Returns: + User input or default + """ + if default: + result = click.prompt(text, default=default, show_default=True) + else: + result = click.prompt(text, default="", show_default=False) + if result == "": + return None + return result if result else None + + +def init_show(config_file_path, config_path): + if not config_file_path.exists(): + click.echo(f"No configuration file found at: {config_file_path}") + click.echo("\nRun 'utils init' to create a configuration.") + return + + try: + config = load_user_config(config_path) + click.echo(f"Current Configuration ({config_file_path}):") + click.echo("=" * 60) + click.echo() + click.echo(format_config_display(config)) + click.echo() + click.echo("To update: utils init") + click.echo("To reset: utils init --reset") + except Exception as e: + click.echo(f"Error reading configuration: {e}", err=True) + click.echo("\nRun 'utils init --reset' to recreate the configuration.") + + +def init_reset(config_file_path, config_path, yes): + if not config_file_path.exists(): + click.echo(f"No configuration file found at: {config_file_path}") + return + + # Show current config + try: + config = load_user_config(config_path) + click.echo(f"This will delete your configuration file at: {config_file_path}") + click.echo() + click.echo("Current configuration:") + for line in format_config_display(config).split("\n"): + click.echo(f" {line}") + click.echo() + except Exception: + click.echo(f"Configuration file exists but may be corrupted: {config_file_path}") + click.echo() + + # Confirm deletion + if not yes: + if not click.confirm("Are you sure you want to reset?", default=False): + click.echo("Reset cancelled.") + return + + # Delete config file + try: + config_file_path.unlink() + click.echo("\n✓ Configuration deleted.") + click.echo("\nRun 'utils init' to create a new configuration.") + except Exception as e: + click.echo(f"Error deleting configuration: {e}", err=True) + + +def build_config(author, organization, project, location, testbed): + # Build configuration + config = {} + + if author: + config["author"] = author + if organization: + config["organization"] = organization + if project: + config["project"] = project + if location: + config["location"] = location + if testbed: + config["testbed"] = testbed + + return config + + +def build_sigmf(license_id, hardware, dataset): + # Build SigMF section + sigmf = {} + + if license_id: + sigmf["license"] = license_id + if hardware: + sigmf["hw"] = hardware + if dataset: + sigmf["dataset"] = dataset + + return sigmf + + +def save_config(config, config_path, use_interactive, warnings): + # Save configuration + try: + saved_path = save_user_config(config, config_path) + click.echo(f"\n✓ Configuration saved to: {saved_path}") + + if use_interactive: + click.echo() + click.echo("You can view your config anytime with: utils init --show") + click.echo("You can update values by running: utils init") + + # Show warnings in non-interactive mode + elif warnings: + click.echo() + click.echo("Warnings:") + for warning in warnings: + click.echo(f" ⚠️ {warning}") + + # TODO message for core team + click.echo() + click.echo("NOTE: Automatic config integration is not yet implemented.") + click.echo("Config values must currently be applied manually with --metadata flags.") + click.echo("(Core team TODO: wire config into capture/convert/transmit commands)") + return 0 + + except Exception as e: + click.echo(f"\nError saving configuration: {e}", err=True) + return 1 + + +@click.command() +@click.option("--author", help="Author name (your name)") +@click.option("--organization", help="Organization/institution name") +@click.option("--project", help="Project name or identifier") +@click.option("--location", help="Physical location (lab name, site, etc.)") +@click.option("--testbed", help="Testbed identifier") +@click.option("--license", "license_id", help="Data license (SPDX identifier, default: Proprietary)") +@click.option("--hw", "hardware", help="Hardware description (e.g., PlutoSDR, USRP B210)") +@click.option("--dataset", help="Dataset identifier") +@click.option("--show", is_flag=True, help="Display current configuration and exit") +@click.option("--reset", is_flag=True, help="Delete existing config") +@click.option("--config-path", type=click.Path(), help="Use alternate config file location") +@click.option("--interactive/--no-interactive", default=None, help="Force interactive mode on/off") +@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompts") +def init( + author, + organization, + project, + location, + testbed, + license_id, + hardware, + dataset, + show, + reset, + config_path, + interactive, + yes, +): + """Initialize user configuration. + + Creates a configuration file at ~/.utils/config.yaml with default metadata + values that will be used across CLI commands. + + Examples: + + \b + # Interactive setup + utils init + + \b + # Non-interactive setup + utils init --author "Jane Doe" --project "RF_Analysis" --location "Lab_A" + + \b + # Show current configuration + utils init --show + + \b + # Reset configuration + utils init --reset + """ + + config_file_path = get_config_path(config_path) + + # Handle --show flag + if show: + init_show(config_file_path, config_path) + return + + # Handle --reset flag + if reset: + init_reset(config_file_path, config_path, yes) + return + + # Determine if we should use interactive mode + # Interactive if: no CLI args provided OR --interactive flag OR config file doesn't exist + has_cli_args = any([author, organization, project, location, testbed, hardware, dataset]) + + if interactive is None: + # Auto-detect: interactive if no args provided + use_interactive = not has_cli_args + else: + use_interactive = interactive + + # Load existing config if it exists + existing_config = None + if config_file_path.exists(): + try: + existing_config = load_user_config(config_path) + except Exception as e: + click.echo(f"Warning: Could not load existing config: {e}", err=True) + click.echo("Creating new configuration...\n") + + # Interactive mode + if use_interactive: + click.echo() + click.echo("Welcome to Utils SDR CLI Configuration!") + click.echo("=" * 60) + click.echo() + click.echo(f"This will create a configuration file at: {config_file_path}") + click.echo() + click.echo("These values will be automatically added to recordings and conversions.") + click.echo("You can always change these later by running 'utils init' again.") + click.echo() + click.echo("Press Enter to skip optional fields.") + click.echo() + + # Required information + click.echo("Required Information:") + click.echo("-" * 20) + + # Use existing values as defaults + author_default = existing_config.get("author", "") if existing_config else "" + org_default = existing_config.get("organization", "") if existing_config else "" + proj_default = existing_config.get("project", "") if existing_config else "" + loc_default = existing_config.get("location", "") if existing_config else "" + test_default = existing_config.get("testbed", "") if existing_config else "" + + author = click.prompt( + "Author name (your name)", default=author_default or "", show_default=bool(author_default) + ) + organization = prompt_with_default("Organization (optional)", org_default) + project = prompt_with_default("Project name (optional)", proj_default) + location = prompt_with_default("Location (optional)", loc_default) + testbed = prompt_with_default("Testbed name (optional)", test_default) + + # SigMF metadata + click.echo() + click.echo("SigMF Metadata (optional):") + click.echo("-" * 27) + + sigmf_defaults = existing_config.get("sigmf", {}) if existing_config else {} + license_default = sigmf_defaults.get("license", "Proprietary") + hw_default = sigmf_defaults.get("hw", "") + dataset_default = sigmf_defaults.get("dataset", "") + + license_id = click.prompt( + "License (e.g., Proprietary, CC-BY-4.0, MIT)", default=license_default, show_default=True + ) + hardware = prompt_with_default("Hardware description (e.g., PlutoSDR)", hw_default) + dataset = prompt_with_default("Dataset name (optional)", dataset_default) + + # Build configuration + config = build_config(author, organization, project, location, testbed) + + # SigMF section + sigmf = build_sigmf(license_id, hardware, dataset) + if sigmf: + config["sigmf"] = sigmf + + # Validate configuration + warnings = validate_config(config) + + # Show configuration summary + if use_interactive: + click.echo() + click.echo("Configuration Summary:") + click.echo("-" * 22) + click.echo(format_config_display(config)) + click.echo() + + # Show warnings + if warnings: + click.echo("Warnings:") + for warning in warnings: + click.echo(f" ⚠️ {warning}") + click.echo() + + # Confirm save + if not yes: + if not click.confirm("Save this configuration?", default=True): + click.echo("Configuration not saved.") + return + + # Save configuration + return save_config(config, config_path, use_interactive, warnings) + + +if __name__ == "__main__": + init() diff --git a/ria_toolkit_oss_cli/ria_toolkit_oss/split.py b/ria_toolkit_oss_cli/ria_toolkit_oss/split.py new file mode 100644 index 0000000..93974d3 --- /dev/null +++ b/ria_toolkit_oss_cli/ria_toolkit_oss/split.py @@ -0,0 +1,421 @@ +"""Split command - Split, trim, and extract portions of recordings.""" + +from pathlib import Path + +import click +import numpy as np + +from utils.io import from_npy_legacy, load_recording +from utils_cli.utils.common import ( + detect_file_format, + echo_progress, + echo_verbose, + format_sample_count, + save_recording, +) + + +def get_output_extension(format_name): + """Get file extension for format name.""" + extension_map = {"sigmf": ".sigmf", "npy": ".npy", "wav": ".wav", "blue": ".blue"} + return extension_map[format_name] + + +def validate_operation(split_at, split_every, split_duration, trim, extract_annotations): + # Validate operation selection + operations = sum( + [split_at is not None, split_every is not None, split_duration is not None, trim, extract_annotations] + ) + + if operations == 0: + raise click.ClickException( + "No operation specified. Use one of:\n" + " --split-at SAMPLE\n" + " --split-every N\n" + " --split-duration SECONDS\n" + " --trim (with --start and --length or --end)\n" + " --extract-annotations" + ) + + if operations > 1: + raise click.ClickException( + "Multiple operations specified. Use only one of:\n" + " --split-at, --split-every, --split-duration, --trim, --extract-annotations" + ) + + +@click.command() +@click.argument("input", type=click.Path(exists=True)) +@click.option("--split-at", type=int, metavar="SAMPLE", help="Split into two files at sample index") +@click.option("--split-every", type=int, metavar="N", help="Split into chunks of N samples") +@click.option( + "--split-duration", + type=float, + metavar="SECONDS", + help="Split into chunks of specified duration (requires sample_rate in metadata)", +) +@click.option("--trim", is_flag=True, help="Extract portion of recording (use with --start and --length or --end)") +@click.option( + "--start", "start_sample", type=int, default=0, show_default=True, help="Start sample for trim operation" +) +@click.option("--length", "num_samples", type=int, help="Number of samples for trim operation") +@click.option("--end", "end_sample", type=int, help="End sample for trim operation (alternative to --length)") +@click.option("--extract-annotations", is_flag=True, help="Extract each annotated region to separate file") +@click.option("--annotation-label", type=str, help="Only extract annotations with this label") +@click.option("--annotation-index", type=int, help="Extract specific annotation by index") +@click.option("--output-dir", type=click.Path(), help="Output directory (default: current directory)") +@click.option("--output-prefix", type=str, help="Prefix for output filenames") +@click.option( + "--output-format", + type=click.Choice(["npy", "sigmf", "wav", "blue"]), + help="Force output format (default: same as input)", +) +@click.option("--overwrite", is_flag=True, help="Overwrite existing output files") +@click.option("--legacy", is_flag=True, help="Load input as legacy NPY format") +@click.option("--verbose", "-v", is_flag=True, help="Verbose output") +@click.option("--quiet", "-q", is_flag=True, help="Suppress output") +def split( # noqa: C901 + input, + split_at, + split_every, + split_duration, + trim, + start_sample, + num_samples, + end_sample, + extract_annotations, + annotation_label, + annotation_index, + output_dir, + output_prefix, + output_format, + overwrite, + legacy, + verbose, + quiet, +): + """Split, trim, and extract portions of recordings. + + Split recordings into multiple files, extract portions, or extract annotated regions. + + \b + Examples: + # Split at specific sample + utils split recording.sigmf --split-at 500000 --output-dir split_output + + \b + # Split into equal chunks + utils split capture.npy --split-every 100000 --output-dir chunks + + \b + # Split by duration (requires sample_rate in metadata) + utils split recording.sigmf --split-duration 1.0 --output-dir segments + + \b + # Trim recording + utils split signal.npy --trim --start 1000 --length 5000 --output-dir trimmed + + \b + # Trim with end index + utils split signal.npy --trim --start 1000 --end 6000 --output-dir trimmed + + \b + # Extract all annotated regions + utils split annotated.sigmf --extract-annotations --output-dir annotations + + \b + # Extract specific annotation label + utils split annotated.sigmf --extract-annotations --annotation-label "payload" + + \b + # Extract specific annotation by index + utils split annotated.sigmf --extract-annotations --annotation-index 1 + """ + + # Validate operation selection + validate_operation(split_at, split_every, split_duration, trim, extract_annotations) + + # Validate trim parameters + if trim: + if num_samples is None and end_sample is None: + raise click.ClickException("Trim operation requires either --length or --end") + if num_samples is not None and end_sample is not None: + raise click.ClickException("Cannot specify both --length and --end") + + # Load input recording + input_path = Path(input) + input_format = detect_file_format(input_path) + + echo_progress(f"Loading: {input_path.name}", quiet) + echo_verbose(f"Input format: {input_format.upper()}", verbose) + + try: + if legacy: + echo_verbose("Using legacy NPY loader", verbose) + recording = from_npy_legacy(input) + else: + recording = load_recording(input) + except Exception as e: + raise click.ClickException(f"Failed to load input file: {e}") + + # Get recording info + if hasattr(recording.data, "shape") and len(recording.data.shape) == 2: + total_samples = recording.data.shape[1] + else: + total_samples = len(recording.data) + + echo_progress(f"Total samples: {format_sample_count(total_samples)}", quiet) + + # Determine output format + if output_format is None: + output_format = input_format + + echo_verbose(f"Output format: {output_format.upper()}", verbose) + + # Determine output directory + if output_dir: + out_dir = Path(output_dir) + else: + out_dir = Path(".") # Current directory + + # Get base filename for outputs + if output_prefix: + base_name = output_prefix + else: + # Get input stem without format-specific suffixes + base_name = input_path.stem + if base_name.endswith(".sigmf-data") or base_name.endswith(".sigmf-meta"): + base_name = base_name[:-11] + elif base_name.endswith(".sigmf"): + base_name = base_name[:-6] + + # Execute operation + if split_at is not None: + # Split at specific sample + if split_at < 0 or split_at >= total_samples: + raise click.ClickException(f"Invalid split point: {split_at}\n" f"Must be between 0 and {total_samples-1}") + + echo_progress(f"\nSplitting at sample {format_sample_count(split_at)}...", quiet) + + # Create two parts + part1 = recording.trim(start_sample=0, num_samples=split_at) + part2 = recording.trim(start_sample=split_at, num_samples=total_samples - split_at) + + # Add metadata about original file + part1._metadata["original_file"] = str(input_path.name) + part1._metadata["original_start_sample"] = 0 + part1._metadata["original_end_sample"] = split_at + part1._metadata["split_operation"] = "split_at" + + part2._metadata["original_file"] = str(input_path.name) + part2._metadata["original_start_sample"] = split_at + part2._metadata["original_end_sample"] = total_samples + part2._metadata["split_operation"] = "split_at" + + # Save parts + ext = get_output_extension(output_format) + output1 = out_dir / f"{base_name}_part1{ext}" + output2 = out_dir / f"{base_name}_part2{ext}" + + echo_progress( + f" Part 1: samples 0-{format_sample_count(split_at-1)} ({format_sample_count(split_at)} samples)", quiet + ) + save_recording(part1, output1, output_format, overwrite, verbose) + + echo_progress( + message=( + f" Part 2: samples {format_sample_count(split_at)}-{format_sample_count(total_samples-1)} " + f"({format_sample_count(total_samples - split_at)} samples)" + ), + quiet=quiet, + ) + save_recording(part2, output2, output_format, overwrite, verbose) + + echo_progress("\nSaved:", quiet) + echo_progress(f" {output1}", quiet) + echo_progress(f" {output2}", quiet) + + elif split_every is not None or split_duration is not None: + # Split into equal chunks + if split_duration is not None: + # Convert duration to samples + sample_rate = recording.metadata.get("sample_rate") + if not sample_rate: + raise click.ClickException( + "Cannot split by duration: no sample_rate in metadata\n" + "Use --split-every with sample count instead" + ) + split_samples = int(split_duration * sample_rate) + echo_progress( + f"\nSplitting into {split_duration}s chunks ({format_sample_count(split_samples)} samples)...", quiet + ) + else: + split_samples = split_every + echo_progress(f"\nSplitting into chunks of {format_sample_count(split_samples)} samples...", quiet) + + if split_samples <= 0: + raise click.ClickException(f"Invalid chunk size: {split_samples}") + + # Calculate number of chunks + num_chunks = int(np.ceil(total_samples / split_samples)) + + echo_progress(f"Creating {num_chunks} chunks...", quiet) + + # Create chunks + ext = get_output_extension(output_format) + created_files = [] + + for i in range(num_chunks): + start = i * split_samples + length = min(split_samples, total_samples - start) + end = start + length - 1 + + # Trim chunk + chunk = recording.trim(start_sample=start, num_samples=length) + + # Add metadata + chunk._metadata["original_file"] = str(input_path.name) + chunk._metadata["original_start_sample"] = start + chunk._metadata["original_end_sample"] = start + length + chunk._metadata["split_operation"] = "split_every" + chunk._metadata["chunk_index"] = i + 1 + chunk._metadata["total_chunks"] = num_chunks + + # Generate output filename + chunk_num = str(i + 1).zfill(len(str(num_chunks))) + output_path = out_dir / f"{base_name}_chunk{chunk_num}{ext}" + + echo_progress( + f" Chunk {i+1}/{num_chunks}: samples {format_sample_count(start)}-{format_sample_count(end)}...", + quiet, + ) + save_recording(chunk, output_path, output_format, overwrite, verbose) + created_files.append(output_path) + + echo_progress(f"\nCreated {num_chunks} chunks in {out_dir}/", quiet) + + elif trim: + # Trim operation + if end_sample is not None: + if end_sample <= start_sample: + raise click.ClickException( + f"Invalid range: end ({end_sample}) must be greater than start ({start_sample})" + ) + num_samples = end_sample - start_sample + + if start_sample < 0 or num_samples < 0: + raise click.ClickException("Invalid trim range: start and length must be non-negative") + + if start_sample + num_samples > total_samples: + raise click.ClickException( + f"Invalid trim range\n" + f"Start: {format_sample_count(start_sample)}, Length: {format_sample_count(num_samples)}, " + f"End: {format_sample_count(start_sample + num_samples)}\n" + f"Recording only has {format_sample_count(total_samples)} samples " + f"(indices 0-{format_sample_count(total_samples-1)})" + ) + + echo_progress("\nTrimming recording...", quiet) + echo_progress(f" Start: {format_sample_count(start_sample)}", quiet) + echo_progress(f" Length: {format_sample_count(num_samples)} samples", quiet) + echo_progress(f" End: {format_sample_count(start_sample + num_samples - 1)}", quiet) + + # Trim recording + trimmed = recording.trim(start_sample=start_sample, num_samples=num_samples) + + # Add metadata + trimmed._metadata["original_file"] = str(input_path.name) + trimmed._metadata["original_start_sample"] = start_sample + trimmed._metadata["original_end_sample"] = start_sample + num_samples + trimmed._metadata["split_operation"] = "trim" + + # Save trimmed recording + ext = get_output_extension(output_format) + output_path = out_dir / f"{base_name}{ext}" + + save_recording(trimmed, output_path, output_format, overwrite, verbose) + + echo_progress(f"\nOutput: {output_path}", quiet) + echo_progress("Done.", quiet) + + elif extract_annotations: + # Extract annotated regions + if not recording.annotations: + raise click.ClickException( + "No annotations found in recording\n" "Use 'utils annotate' to add annotations first" + ) + + # Filter annotations + annotations_to_extract = recording.annotations + + if annotation_index is not None: + if annotation_index < 0 or annotation_index >= len(annotations_to_extract): + raise click.ClickException( + f"Invalid annotation index: {annotation_index}\n" + f"Recording has {len(annotations_to_extract)} annotations " + f"(indices 0-{len(annotations_to_extract)-1})" + ) + annotations_to_extract = [annotations_to_extract[annotation_index]] + + if annotation_label is not None: + filtered = [ann for ann in annotations_to_extract if ann.label == annotation_label] + if not filtered: + available_labels = list(set(ann.label for ann in recording.annotations)) + raise click.ClickException( + f"No annotations with label '{annotation_label}'\n" + f"Available labels: {', '.join(available_labels)}" + ) + annotations_to_extract = filtered + + echo_progress(f"\nExtracting {len(annotations_to_extract)} annotated region(s)...", quiet) + + # Extract each annotation + ext = get_output_extension(output_format) + created_files = [] + + for ann in annotations_to_extract: + # Get annotation bounds + start = ann.sample_start + count = ann.sample_count + end = start + count - 1 + + # Trim to annotation bounds + chunk = recording.trim(start_sample=start, num_samples=count) + + # Clear annotations - the trimmed chunk IS the annotation, + # and trim() may produce invalid annotations + chunk._annotations = [] + + # Add metadata + chunk._metadata["original_file"] = str(input_path.name) + chunk._metadata["original_start_sample"] = start + chunk._metadata["original_end_sample"] = start + count + chunk._metadata["split_operation"] = "extract_annotation" + chunk._metadata["annotation_label"] = ann.label + + # Generate filename + label_safe = ann.label.replace(" ", "_").replace("/", "_") + output_filename = f"{base_name}_{label_safe}_{start}-{start+count}{ext}" + output_path = out_dir / output_filename + + # Get original index in full annotation list if we filtered + if annotation_index is not None: + display_idx = annotation_index + else: + display_idx = recording.annotations.index(ann) + + echo_progress( + message=( + f" [{display_idx}] {ann.label} ({format_sample_count(start)}" + f"-{format_sample_count(end)}): {output_filename}" + ), + quiet=quiet, + ) + save_recording(chunk, output_path, output_format, overwrite, verbose) + created_files.append(output_path) + + echo_progress(f"\nExtracted {len(annotations_to_extract)} annotated region(s).", quiet) + + +if __name__ == "__main__": + split() diff --git a/ria_toolkit_oss_cli/ria_toolkit_oss/transform.py b/ria_toolkit_oss_cli/ria_toolkit_oss/transform.py new file mode 100644 index 0000000..e43d2f4 --- /dev/null +++ b/ria_toolkit_oss_cli/ria_toolkit_oss/transform.py @@ -0,0 +1,732 @@ +"""Transform command - Apply signal transformations to recordings.""" + +import importlib +import importlib.util +import inspect +import os +from pathlib import Path + +import click + +from utils.data.recording import Recording +from utils.io.recording import load_recording +from utils.transforms import iq_augmentations, iq_channel_models, iq_impairments +from utils_cli.utils.common import ( + echo_progress, + echo_verbose, + format_sample_count, + save_recording, +) + + +def get_available_transforms(module): + """Get list of public transform functions from a module. + + Args: + module: Python module to inspect + + Returns: + dict: {name: function} for all public callables + """ + transforms = {} + for name, obj in inspect.getmembers(module, inspect.isfunction): + if not name.startswith("_"): + transforms[name] = obj + return transforms + + +def get_transform_help(func): + """Extract help info from a transform function. + + Args: + func: Transform function to inspect + + Returns: + dict: {description, params} + """ + sig = inspect.signature(func) + doc = inspect.getdoc(func) or "" + + # Get first line of docstring as description + description = doc.split("\n")[0] if doc else "No description" + + # Extract parameters from signature (skip 'signal') + params = {} + for param_name, param in sig.parameters.items(): + if param_name == "signal": + continue + + default = param.default + param_type = "optional" if default != inspect.Parameter.empty else "required" + default_str = f" (default: {default})" if default != inspect.Parameter.empty else "" + + params[param_name] = { + "type": param_type, + "default": default, + "annotation": str(param.annotation) if param.annotation != inspect.Parameter.empty else "any", + "display": f"{param_name} ({param_type}){default_str}", + } + + return {"description": description, "full_doc": doc, "params": params} + + +def show_transform_help(transform_name, func): + """Display compact help for a specific transform.""" + info = get_transform_help(func) + + click.echo(f"\n{transform_name}") + click.echo("-" * 50) + click.echo(info["description"]) + + if info["params"]: + click.echo("\nParameters:") + for param_name, param_info in sorted(info["params"].items()): + click.echo(f" {param_name:20} {param_info['display']}") + + click.echo() + + +def quick_view_transform(recording, output_path, title="Transform Result"): + """Create a quick PNG visualization of transformed recording using constellation plot.""" + try: + from utils.view.view_signal_simple import view_simple_sig + + # Create PNG in same directory as output + output_dir = Path(output_path).parent + base_name = Path(output_path).stem + png_path = output_dir / f"{base_name}_preview.png" + + # Use simple view with constellation + view_simple_sig(recording, output_path=str(png_path), constellation_mode=True, title=title, saveplot=True) + + click.echo(f"Visualization saved to: {png_path}") + except Exception as e: + click.echo(f"Warning: Could not create visualization: {e}") + + +def generate_transform_suffix(transform_name, params): + """Generate a short suffix for the output filename based on transform and params. + + Args: + transform_name: Name of the transform + params: Dict of parameters + + Returns: + str: A short suffix like "awgn15" or "freqoffset10k" + """ + suffix = transform_name.replace("_", "") + + # Add key parameter values + if "snr_db" in params: + suffix += f"{int(params['snr_db'])}" + elif "snr" in params: + suffix += f"{int(params['snr'])}" + elif "amplitude_variance" in params: + suffix += f"{int(params['amplitude_variance']*100)}av" + elif "phase_variance" in params: + suffix += f"{int(params['phase_variance']*100000)}pv" + elif "compression_gain" in params: + suffix += f"{params['compression_gain']:.2f}".rstrip("0").rstrip(".") + elif "offset_hz" in params: + hz = params["offset_hz"] + if abs(hz) >= 1e6: + suffix += f"{hz/1e6:.0f}m" + elif abs(hz) >= 1e3: + suffix += f"{hz/1e3:.0f}k" + else: + suffix += f"{hz:.0f}" + elif "offset" in params: + suffix += f"{params['offset']:.2f}".rstrip("0").rstrip(".") + elif "doppler_hz" in params: + suffix += f"{params['doppler_hz']:.0f}" + + return suffix + + +def parse_transform_params(param_strings): + """Parse transform parameters from CLI options. + + Args: + param_strings: List of 'KEY=VALUE' strings + + Returns: + dict: {key: value} with types inferred + """ + params = {} + if not param_strings: + return params + + for param_str in param_strings: + if "=" not in param_str: + raise click.BadParameter(f"Parameter must be KEY=VALUE, got: {param_str}") + + key, value = param_str.split("=", 1) + key = key.strip() + value = value.strip() + + # Try to infer type + try: + # Try to parse scientific notation and floats + if "e" in value.lower() or "." in value: + params[key] = float(value) + else: + params[key] = int(value) + except ValueError: + # Keep as string + params[key] = value + + return params + + +def load_custom_transforms(transform_dir): + """Load custom transform functions from a directory. + + Args: + transform_dir: Path to directory containing .py files with transform functions + + Returns: + dict: {transform_name: function} for all public functions in all .py files + + Raises: + click.ClickException: If directory doesn't exist or no transforms found + """ + transform_dir = Path(transform_dir) + + if not transform_dir.exists(): + raise click.ClickException(f"Transform directory does not exist: {transform_dir}") + + if not transform_dir.is_dir(): + raise click.ClickException(f"Path is not a directory: {transform_dir}") + + transforms = {} + py_files = list(transform_dir.glob("*.py")) + + if not py_files: + raise click.ClickException(f"No .py files found in {transform_dir}") + + for py_file in py_files: + try: + # Load module dynamically + spec = importlib.util.spec_from_file_location(py_file.stem, py_file) + if spec is None or spec.loader is None: + click.echo(f"Warning: Could not load {py_file.name}") + continue + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + # Extract all public functions + for name, obj in inspect.getmembers(module, inspect.isfunction): + if not name.startswith("_"): + # Store with source file info for metadata + obj._transform_source_file = py_file.name + transforms[name] = obj + except Exception as e: + raise click.ClickException(f"Failed to load {py_file.name}: {e}") + + return transforms + + +def check_input_errors(item_name: str, item, available, input, help_transform): + if item is None: + if help_transform: + raise click.UsageError(f"{item_name.upper()} must be specified for --help-transform") + else: + raise click.UsageError(f"{item_name.upper()} must be specified (or use --list)") + if item not in available: + raise click.ClickException(f"Unknown {item_name}: {item}\n" f"Use --list to see available options") + if input is None and not help_transform: + raise click.UsageError("INPUT must be specified") + + +def load_input(input, verbose): + # Load input + try: + recording = load_recording(input) + except Exception as e: + raise click.ClickException(f"Failed to load input: {e}") + + echo_verbose(f"Loaded {format_sample_count(recording.data.shape[-1])} samples", verbose) + return recording + +@click.group() +def transform(): + """Apply signal transformations to recordings. + + Transform supports three categories of operations: + - augment: Modify signal to create new ML examples + - impair: Degrade signal with noise, distortion, etc. + - apply_channel: Apply channel models (fading, Doppler, etc.) + + Each operation is applied independently. Chain multiple transforms by + running this command multiple times. + + \b + Examples: + # List available augmentations + utils transform augment --list + \b + # Apply channel swap + utils transform augment channel_swap input.npy + \b + # Apply AWGN impairment + utils transform impair awgn input.npy --snr-db 15 + \b + # Apply Rayleigh fading channel + utils transform apply_channel rayleigh input.npy --num-paths 5 + """ + pass + + +@transform.command(name="augment") +@click.argument("augmentation", required=False) +@click.argument("input", type=click.Path(exists=True), required=False) +@click.argument("output", type=click.Path(), required=False) +@click.option("--list", "list_transforms", is_flag=True, help="List available augmentations") +@click.option("--help-transform", is_flag=True, help="Show parameters for this augmentation") +@click.option("--params", multiple=True, help="Transform parameters as KEY=VALUE (can be repeated)") +@click.option("--view", is_flag=True, help="Save visualization PNG with constellation plot") +@click.option("--overwrite", is_flag=True, help="Overwrite output if it exists") +@click.option("--verbose", "-v", is_flag=True, help="Verbose output") +@click.option("--quiet", "-q", is_flag=True, help="Suppress output") +def augment(augmentation, input, output, list_transforms, help_transform, params, view, overwrite, verbose, quiet): + """Apply augmentation transforms to recordings. + + Augmentations modify signals to create new training examples without + degrading quality (e.g., channel swap, time reversal, quantization). + + Examples: + + # List all augmentations + \b + utils transform augment --list + + # Show parameters for an augmentation + \b + utils transform augment channel_swap --help-transform + + # Apply augmentation + \b + utils transform augment channel_swap input.npy + + # Apply with parameters and save visualization + \b + utils transform augment drop_samples input.npy --params max_section_size=5 --view + """ + available = get_available_transforms(iq_augmentations) + + if list_transforms: + click.echo("Available augmentations:") + for name in sorted(available.keys()): + func = available[name] + docstring = (func.__doc__ or "").split("\n")[0].strip() + click.echo(f" {name:30} {docstring}") + return + + if help_transform: + check_input_errors("augmentation", augmentation, available, input, help_transform) + show_transform_help(augmentation, available[augmentation]) + return + + check_input_errors("augmentation", augmentation, available, input, help_transform) + + # Generate output filename if not provided + if output is None: + input_path = Path(input) + input_stem = input_path.stem + ext = input_path.suffix + suffix = generate_transform_suffix(augmentation, parse_transform_params(params)) + output = str(input_path.parent / f"{input_stem}_{suffix}{ext}") + echo_verbose(f"Auto-generated output: {output}", verbose) + + # Check if output exists + if not overwrite and Path(output).exists(): + raise click.ClickException(f"Output file '{output}' already exists\n" f"Use --overwrite to replace") + + echo_progress(f"Augmenting: {os.path.basename(input)} → {os.path.basename(output)}", quiet) + echo_verbose(f"Transform: {augmentation}", verbose) + + # Load input + recording = load_input(input, verbose) + + # Parse and apply transform + try: + transform_func = available[augmentation] + transform_params = parse_transform_params(params) + echo_verbose(f"Parameters: {transform_params}", verbose) + + result = transform_func(recording, **transform_params) + except Exception as e: + raise click.ClickException(f"Transform failed: {e}") + + # Track transform in metadata (Recording.metadata is a property that returns a copy) + # So we need to work with a copy and create a new Recording with updated metadata + updated_metadata = result.metadata.copy() + if "transforms_applied" not in updated_metadata: + updated_metadata["transforms_applied"] = [] + + updated_metadata["transforms_applied"].append( + {"type": "augment", "name": augmentation, "params": parse_transform_params(params)} + ) + + # Create new recording with updated metadata + result = Recording(data=result.data, metadata=updated_metadata, annotations=result.annotations) + + # Save output + try: + save_recording(result, output, overwrite=overwrite, verbose=verbose) + echo_progress(f"Saved to: {output}", quiet) + except Exception as e: + raise click.ClickException(f"Failed to save output: {e}") + + # Optional: Create visualization + if view: + echo_verbose("Creating visualization...", verbose) + quick_view_transform(result, output, title=f"{augmentation.replace('_', ' ').title()} - {Path(output).name}") + + +@transform.command(name="impair") +@click.argument("impairment", required=False) +@click.argument("input", type=click.Path(exists=True), required=False) +@click.argument("output", type=click.Path(), required=False) +@click.option("--list", "list_transforms", is_flag=True, help="List available impairments") +@click.option("--help-transform", is_flag=True, help="Show parameters for this impairment") +@click.option("--params", multiple=True, help="Transform parameters as KEY=VALUE (can be repeated)") +@click.option("--view", is_flag=True, help="Save visualization PNG with constellation plot") +@click.option("--overwrite", is_flag=True, help="Overwrite output if it exists") +@click.option("--verbose", "-v", is_flag=True, help="Verbose output") +@click.option("--quiet", "-q", is_flag=True, help="Suppress output") +def impair(impairment, input, output, list_transforms, help_transform, params, view, overwrite, verbose, quiet): + """Apply impairment transforms to recordings. + + Impairments degrade signals by adding noise, distortion, and other + channel effects (e.g., AWGN, phase noise, IQ imbalance). + + Examples: + + # List all impairments + \b + utils transform impair --list + + # Show parameters for an impairment + \b + utils transform impair add_awgn_to_signal --help-transform + + # Apply impairment + \b + utils transform impair add_awgn_to_signal input.npy --params snr=10 + + # Apply with visualization + \b + utils transform impair add_phase_noise input.npy --params phase_variance=0.001 --view + """ + available = get_available_transforms(iq_impairments) + + if list_transforms: + click.echo("Available impairments:") + for name in sorted(available.keys()): + func = available[name] + docstring = (func.__doc__ or "").split("\n")[0].strip() + click.echo(f" {name:30} {docstring}") + return + + if help_transform: + check_input_errors("impairment", impairment, available, input, help_transform) + show_transform_help(impairment, available[impairment]) + return + + check_input_errors("impairment", impairment, available, input, help_transform) + + # Generate output filename if not provided + if output is None: + input_path = Path(input) + input_stem = input_path.stem + ext = input_path.suffix + suffix = generate_transform_suffix(impairment, parse_transform_params(params)) + output = str(input_path.parent / f"{input_stem}_{suffix}{ext}") + echo_verbose(f"Auto-generated output: {output}", verbose) + + # Check if output exists + if not overwrite and Path(output).exists(): + raise click.ClickException(f"Output file '{output}' already exists\n" f"Use --overwrite to replace") + + echo_progress(f"Impairing: {os.path.basename(input)} → {os.path.basename(output)}", quiet) + echo_verbose(f"Transform: {impairment}", verbose) + + # Load input + recording = load_input(input, verbose) + + # Parse and apply transform + try: + transform_func = available[impairment] + transform_params = parse_transform_params(params) + echo_verbose(f"Parameters: {transform_params}", verbose) + + result = transform_func(recording, **transform_params) + except Exception as e: + raise click.ClickException(f"Transform failed: {e}") + + # Track transform in metadata (Recording.metadata is a property that returns a copy) + updated_metadata = result.metadata.copy() + if "transforms_applied" not in updated_metadata: + updated_metadata["transforms_applied"] = [] + + updated_metadata["transforms_applied"].append( + {"type": "impair", "name": impairment, "params": parse_transform_params(params)} + ) + + # Create new recording with updated metadata + result = Recording(data=result.data, metadata=updated_metadata, annotations=result.annotations) + + # Save output + try: + save_recording(result, output, overwrite=overwrite, verbose=verbose) + echo_progress(f"Saved to: {output}", quiet) + except Exception as e: + raise click.ClickException(f"Failed to save output: {e}") + + # Optional: Create visualization + if view: + echo_verbose("Creating visualization...", verbose) + quick_view_transform(result, output, title=f"{impairment.replace('_', ' ').title()} - {Path(output).name}") + + +@transform.command(name="apply_channel") +@click.argument("channel_model", required=False) +@click.argument("input", type=click.Path(exists=True), required=False) +@click.argument("output", type=click.Path(), required=False) +@click.option("--list", "list_transforms", is_flag=True, help="List available channel models") +@click.option("--help-transform", is_flag=True, help="Show parameters for this channel model") +@click.option("--params", multiple=True, help="Transform parameters as KEY=VALUE (can be repeated)") +@click.option("--view", is_flag=True, help="Save visualization PNG with constellation plot") +@click.option("--overwrite", is_flag=True, help="Overwrite output if it exists") +@click.option("--verbose", "-v", is_flag=True, help="Verbose output") +@click.option("--quiet", "-q", is_flag=True, help="Suppress output") +def apply_channel( + channel_model, input, output, list_transforms, help_transform, params, view, overwrite, verbose, quiet +): + """Apply channel models to recordings. + + Channel models simulate RF propagation effects like fading, Doppler shift, + and multipath reflections. + + Use --list to see available channel models and their parameters. + + \b + Examples: + utils transform apply_channel rayleigh_fading_channel input.npy --params num_paths=3 snr_db=15 + + \b + utils transform apply_channel doppler_channel recordings/input.npy \\ + --params satellite_velocity=7500 \\ + --params satellite_initial_distance=400000 \\ + --params frequency=1e9 \\ + --params sample_rate=2e6 + """ + available = get_available_transforms(iq_channel_models) + + if list_transforms: + click.echo("Available channel models:") + for name in sorted(available.keys()): + func = available[name] + docstring = (func.__doc__ or "").split("\n")[0].strip() + click.echo(f" {name:30} {docstring}") + return + + if help_transform: + check_input_errors("channel_model", channel_model, available, input, help_transform) + show_transform_help(channel_model, available[channel_model]) + return + + check_input_errors("channel_model", channel_model, available, input, help_transform) + + # Generate output filename if not provided + if output is None: + input_path = Path(input) + input_stem = input_path.stem + ext = input_path.suffix + suffix = generate_transform_suffix(channel_model, parse_transform_params(params)) + output = str(input_path.parent / f"{input_stem}_{suffix}{ext}") + echo_verbose(f"Auto-generated output: {output}", verbose) + + # Check if output exists + if not overwrite and Path(output).exists(): + raise click.ClickException(f"Output file '{output}' already exists\n" f"Use --overwrite to replace") + + echo_progress(f"Applying channel: {os.path.basename(input)} → {os.path.basename(output)}", quiet) + echo_verbose(f"Channel model: {channel_model}", verbose) + + # Load input + recording = load_input(input, verbose) + + # Parse and apply transform + try: + transform_func = available[channel_model] + transform_params = parse_transform_params(params) + echo_verbose(f"Parameters: {transform_params}", verbose) + + result = transform_func(recording, **transform_params) + except Exception as e: + raise click.ClickException(f"Transform failed: {e}") + + # Track transform in metadata (Recording.metadata is a property that returns a copy) + updated_metadata = result.metadata.copy() + if "transforms_applied" not in updated_metadata: + updated_metadata["transforms_applied"] = [] + + updated_metadata["transforms_applied"].append( + {"type": "channel", "name": channel_model, "params": parse_transform_params(params)} + ) + + # Create new recording with updated metadata + result = Recording(data=result.data, metadata=updated_metadata, annotations=result.annotations) + + # Save output + try: + save_recording(result, output, overwrite=overwrite, verbose=verbose) + echo_progress(f"Saved to: {output}", quiet) + except Exception as e: + raise click.ClickException(f"Failed to save output: {e}") + + # Optional: Create visualization + if view: + echo_verbose("Creating visualization...", verbose) + quick_view_transform(result, output, title=f"{channel_model.replace('_', ' ').title()} - {Path(output).name}") + + +@transform.command(name="custom") +@click.argument("transform_name", required=False) +@click.argument("input", type=click.Path(exists=True), required=False) +@click.argument("output", type=click.Path(), required=False) +@click.option( + "--transform-dir", + type=click.Path(exists=True), + required=True, + help="Path to directory containing custom transform .py files", +) +@click.option("--list", "list_transforms", is_flag=True, help="List available custom transforms") +@click.option("--help-transform", is_flag=True, help="Show parameters for this transform") +@click.option("--params", multiple=True, help="Transform parameters as KEY=VALUE (can be repeated)") +@click.option("--view", is_flag=True, help="Save visualization PNG with constellation plot") +@click.option("--overwrite", is_flag=True, help="Overwrite output if it exists") +@click.option("--verbose", "-v", is_flag=True, help="Verbose output") +@click.option("--quiet", "-q", is_flag=True, help="Suppress output") +def custom( + transform_name, + input, + output, + transform_dir, + list_transforms, + help_transform, + params, + view, + overwrite, + verbose, + quiet, +): + """Apply custom user-defined transforms to recordings. + + Custom transforms are Python functions loaded from user-specified directory. + Each .py file in the directory is scanned for public functions that can be used. + + Transform functions must have signature: + def my_transform(signal, **kwargs) -> signal_or_recording + where signal is a complex CxN array or Recording object. + + Examples: + + # List all custom transforms in directory + \b + utils transform custom --transform-dir ~/my_transforms --list + + # Show parameters for a transform + \b + utils transform custom my_filter --transform-dir ~/my_transforms --help-transform + + # Apply custom transform + \b + utils transform custom my_filter input.npy --transform-dir ~/my_transforms + + # With parameters and visualization + \b + utils transform custom my_filter input.npy --transform-dir ~/my_transforms \\ + --params cutoff_freq=5000 order=4 --view + """ + try: + available = load_custom_transforms(transform_dir) + except click.ClickException: + raise + + if list_transforms: + click.echo(f"Available custom transforms in {transform_dir}:") + for name in sorted(available.keys()): + func = available[name] + source_file = getattr(func, "_transform_source_file", "unknown") + docstring = (func.__doc__ or "").split("\n")[0].strip() + click.echo(f" {name:30} {docstring:40} [{source_file}]") + return + + if help_transform: + check_input_errors("transform_name", transform_name, available, input, help_transform) + show_transform_help(transform_name, available[transform_name]) + return + + check_input_errors("transform_name", transform_name, available, input, help_transform) + + # Generate output filename if not provided + if output is None: + input_path = Path(input) + input_stem = input_path.stem + ext = input_path.suffix + suffix = generate_transform_suffix(transform_name, parse_transform_params(params)) + output = str(input_path.parent / f"{input_stem}_{suffix}{ext}") + echo_verbose(f"Auto-generated output: {output}", verbose) + + # Check if output exists + if not overwrite and Path(output).exists(): + raise click.ClickException(f"Output file '{output}' already exists\n" f"Use --overwrite to replace") + + echo_progress(f"Applying custom: {os.path.basename(input)} → {os.path.basename(output)}", quiet) + echo_verbose(f"Transform: {transform_name}", verbose) + + # Load input + recording = load_input(input, verbose) + + # Parse and apply transform + try: + transform_func = available[transform_name] + transform_params = parse_transform_params(params) + echo_verbose(f"Parameters: {transform_params}", verbose) + + result = transform_func(recording, **transform_params) + except Exception as e: + raise click.ClickException(f"Transform failed: {e}") + + # Track transform in metadata + updated_metadata = result.metadata.copy() + if "transforms_applied" not in updated_metadata: + updated_metadata["transforms_applied"] = [] + + updated_metadata["transforms_applied"].append( + { + "type": "custom", + "name": transform_name, + "source_file": getattr(available[transform_name], "_transform_source_file", "unknown"), + "params": parse_transform_params(params), + } + ) + + # Create new recording with updated metadata + result = Recording(data=result.data, metadata=updated_metadata, annotations=result.annotations) + + # Save output + try: + save_recording(result, output, overwrite=overwrite, verbose=verbose) + echo_progress(f"Saved to: {output}", quiet) + except Exception as e: + raise click.ClickException(f"Failed to save output: {e}") + + # Optional: Create visualization + if view: + echo_verbose("Creating visualization...", verbose) + quick_view_transform(result, output, title=f"{transform_name.replace('_', ' ').title()} - {Path(output).name}") diff --git a/ria_toolkit_oss_cli/ria_toolkit_oss/transmit.py b/ria_toolkit_oss_cli/ria_toolkit_oss/transmit.py new file mode 100644 index 0000000..db3c4bb --- /dev/null +++ b/ria_toolkit_oss_cli/ria_toolkit_oss/transmit.py @@ -0,0 +1,499 @@ +"""Transmit command for SDR devices.""" + +import os +import signal +import time + +import click + +from utils.data import Recording +from utils.io import from_npy_legacy, load_recording + +from .common import ( + echo_progress, + echo_verbose, + format_frequency, + format_sample_rate, + get_sdr_device, + load_yaml_config, + parse_frequency, +) +from .discover import ( + find_bladerf_devices, + find_hackrf_devices, + find_pluto_devices, + find_uhd_devices, + load_sdr_drivers, +) + +# TX-capable devices (RTL-SDR and ThinkRF are RX-only) +TX_CAPABLE_DEVICES = ["pluto", "hackrf", "bladerf", "usrp"] + + +def auto_select_tx_device(quiet: bool = False) -> str: + """ + Auto-select TX-capable device if only one is connected. + + Args: + quiet: Suppress warning messages + + Returns: + Device type string + + Raises: + click.ClickException: If no TX devices or multiple devices found + """ + # Load drivers and collect TX-capable devices only + load_sdr_drivers(verbose=False) + + tx_devices = [] + tx_devices.extend(find_uhd_devices()) + tx_devices.extend(find_pluto_devices()) + tx_devices.extend(find_hackrf_devices()) + tx_devices.extend(find_bladerf_devices()) + # Note: RTL-SDR and ThinkRF excluded (RX-only) + + if len(tx_devices) == 0: + raise click.ClickException( + "No TX-capable SDR devices found.\n" + "TX-capable devices: PlutoSDR, HackRF, BladeRF, USRP\n" + "Run 'utils discover' to see all devices." + ) + + elif len(tx_devices) == 1: + device = tx_devices[0] + device_type = device.get("type", "Unknown").lower().replace("-", "").replace(" ", "") + + # Map device type names to internal names + type_map = { + "plutosdr": "pluto", + "hackrf": "hackrf", + "hackrfone": "hackrf", + "bladerf": "bladerf", + "usrp": "usrp", + "b200": "usrp", + "b210": "usrp", + } + + device_type = type_map.get(device_type, device_type) + + if not quiet: + click.echo( + click.style("Warning: ", fg="yellow") + + f"No device specified. Auto-detected {device.get('type', 'Unknown')}", + err=True, + ) + click.echo(f"Use --device {device_type} to suppress this warning.\n", err=True) + + return device_type + + else: + device_list = "\n".join(f" - {d.get('type', 'Unknown')}" for d in tx_devices) + raise click.ClickException( + f"Multiple TX-capable devices found. Specify with --device\n\n" + f"Available TX devices:\n{device_list}\n\n" + f"Run 'utils discover' for more details." + ) + + +def load_input_file(input_file: str, legacy: bool = False) -> Recording: + """ + Load recording from file with auto-format detection. + + Args: + input_file: Path to input file + legacy: Use legacy NPY loader + + Returns: + Recording object + + Raises: + click.ClickException: If file not found or format unsupported + """ + if not os.path.exists(input_file): + raise click.ClickException(f"Input file not found: {input_file}") + + try: + if legacy: + echo_progress("Loading legacy NPY file...", quiet=False) + recording = from_npy_legacy(input_file) + else: + echo_progress("Loading input file...", quiet=False) + recording = load_recording(input_file) + + return recording + + except Exception as e: + raise click.ClickException( + f"Could not load '{input_file}': {e}\n" + f"Supported formats: .sigmf, .npy, .wav, .blue\n" + f"Use --legacy for old NPY format files" + ) + + +def select_params(device, sample_rate, gain, bandwidth, quiet, verbose): + # Auto-select device if not specified + if device is None: + device = auto_select_tx_device(quiet) + + # Apply device-specific defaults (matching signal-testbed but conservative for TX) + if sample_rate is None: + # TX sample rate defaults (same as RX) + device_sample_rates = { + "pluto": 20e6, # PlutoSDR up to 61 MHz, 20 MHz safe + "hackrf": 20e6, # HackRF up to 20 MHz + "bladerf": 40e6, # BladeRF up to 61 MHz, 40 MHz safe + "usrp": 50e6, # USRP up to 200 MHz, 50 MHz default + } + sample_rate = device_sample_rates.get(device, 20e6) + + if gain is None: + # TX gain defaults (conservative for ISM band to avoid interference) + default_tx_gains = { + "pluto": -20, # PlutoSDR: -20 dB (safe, low power) + "hackrf": 0, # HackRF: 0 dB (moderate) + "bladerf": -10, # BladeRF: -10 dB (conservative) + "usrp": -10, # USRP: -10 dB (conservative) + } + gain = default_tx_gains.get(device, -10) + echo_verbose(f"Using default TX gain: {gain} dB for {device}", verbose) + + if bandwidth is None: + # Bandwidth defaults (match sample rate) + device_bandwidths = { + "pluto": sample_rate, + "hackrf": sample_rate, + "bladerf": sample_rate, + "usrp": sample_rate, + } + bandwidth = device_bandwidths.get(device) + + return device, sample_rate, gain, bandwidth + + +def validate_tx_gain(device_type: str, gain: float) -> None: + """ + Validate TX gain is within device limits and warn if at extremes. + + Args: + device_type: Type of device + gain: TX gain in dB + + Raises: + click.ClickException: If gain is out of range + """ + gain_ranges = { + "pluto": (-89, 0), + "hackrf": (0, 47), + "bladerf": (-15, 60), + "usrp": (-30, 20), # Approximate, varies by model + } + + if device_type in gain_ranges: + min_gain, max_gain = gain_ranges[device_type] + + if gain < min_gain or gain > max_gain: + raise click.ClickException( + f"TX gain {gain} dB is out of range for {device_type}\n" f"Valid range: {min_gain} to {max_gain} dB" + ) + + # Warn if at maximum + if gain >= max_gain - 3: + click.echo( + click.style("WARNING: ", fg="yellow", bold=True) + f"Transmitting at high gain level ({gain} dB)\n" + f"Maximum for {device_type}: {max_gain} dB", + err=True, + ) + + +def generate_recording(generate, input_file, sample_rate, verbose, legacy): + # Generate signal or load from file + if generate or input_file is None: + # Generate signal instead of loading from file + from utils.signal.basic_signal_generator import ( + chirp, + lfm_chirp_complex, + sine, + square, + ) + + # Calculate number of samples for signal generation (default: 0.1 second = 100ms) + # Shorter duration to avoid buffer issues with large sample rates + num_samples = int(sample_rate * 0.1) # 100ms of signal + + if generate == "lfm" or (generate is None and input_file is None): + # Generate LFM chirp (default - visible on spectrogram) + echo_verbose("Generating LFM chirp signal...", verbose) + recording = lfm_chirp_complex( + sample_rate=int(sample_rate), + width=int(sample_rate * 0.4), # 40% of sample rate (safe for filter) + chirp_period=0.001, # 1ms chirp period + sigfc=0, # Baseband + total_time=num_samples / sample_rate, + chirp_type="up", + ) + echo_verbose(f"Generated {len(recording.data)} sample LFM chirp", verbose) + + elif generate == "chirp": + # Generate simple chirp + echo_verbose("Generating chirp signal...", verbose) + recording = chirp(sample_rate=int(sample_rate), num_samples=num_samples, center_frequency=0) # Baseband + echo_verbose(f"Generated {len(recording.data)} sample chirp", verbose) + + elif generate == "sine": + # Generate sine wave at 10% offset from center + echo_verbose("Generating sine wave signal...", verbose) + recording = sine( + sample_rate=int(sample_rate), + length=num_samples, + frequency=sample_rate * 0.1, # 10% offset + amplitude=0.8, + ) + echo_verbose(f"Generated {len(recording.data)} sample sine wave", verbose) + + elif generate == "pulse": + # Generate pulse using square wave + echo_verbose("Generating pulse signal...", verbose) + recording = square( + sample_rate=int(sample_rate), + length=num_samples, + frequency=1000, # 1 kHz pulse + amplitude=0.8, + duty_cycle=0.1, # 10% duty cycle for pulse + ) + echo_verbose(f"Generated {len(recording.data)} sample pulse", verbose) + + return recording + + elif input_file: + # Load input file + return load_input_file(input_file, legacy=legacy) + + else: + raise click.ClickException("Either --input or --generate must be specified") + + +def check_sample_rate_mismatch(recording: Recording, specified_rate: float, quiet: bool) -> None: + """ + Check if recording sample rate differs from specified rate. + + Args: + recording: Recording object + specified_rate: Specified sample rate + quiet: Suppress warnings + """ + if hasattr(recording, "metadata") and recording.metadata: + recorded_rate = recording.metadata.get("sample_rate") + if recorded_rate and abs(recorded_rate - specified_rate) > 1: + if not quiet: + click.echo( + click.style("Warning: ", fg="yellow") + + f"Recording sample rate ({format_sample_rate(recorded_rate)}) differs " + f"from specified rate ({format_sample_rate(specified_rate)})\n" + f"Using specified rate. Signal may be distorted.", + err=True, + ) + + +def repeated_transmission(sdr, recording, repeat, tx_delay, quiet, verbose): + for i in range(repeat): + if repeat > 1: + echo_progress(f"\nTransmission {i + 1}/{repeat}...", quiet) + + sdr.tx_recording(recording) + + if repeat > 1: + echo_progress(f"Transmission {i + 1}/{repeat} complete.", quiet) + + # Delay between transmissions + if i < repeat - 1 and tx_delay > 0: + echo_verbose(f"Waiting {tx_delay}s before next transmission...", verbose) + time.sleep(tx_delay) + + if repeat > 1: + echo_progress(f"\nAll {repeat} transmissions complete.", quiet) + + +@click.command() +@click.option("--device", "-d", type=click.Choice(TX_CAPABLE_DEVICES), help="Device type (TX-capable only)") +@click.option("--ident", "-i", help="Device identifier (IP address or name=value, e.g., 192.168.2.1 or name=myb210)") +@click.option( + "--config", "-c", "config_file", type=click.Path(exists=True), help="Load parameters from YAML config file" +) +@click.option( + "--sample-rate", "-s", type=float, default=None, help="Sample rate in Hz (e.g., 2e6) [default: device-specific]" +) +@click.option( + "--center-frequency", + "-f", + type=str, + default="2440M", + show_default=True, + help="Center frequency (e.g., 915e6, 2.4G)", +) +@click.option("--gain", "-g", type=float, help="TX gain in dB [default: device-specific safe level]") +@click.option("--bandwidth", "-b", type=float, help="Bandwidth in Hz (if supported) [default: device-specific]") +@click.option( + "--input", + "-in", + "input_file", + type=click.Path(), + help=( + "Input recording file (auto-detects format). " + "If omitted and --generate not specified, generates default LFM chirp." + ), +) +@click.option("--legacy", is_flag=True, help="Use legacy NPY format loader") +@click.option( + "--generate", + type=click.Choice(["lfm", "chirp", "sine", "pulse"]), + help="Generate signal instead of loading from file (overrides --input)", +) +@click.option("--repeat", "-r", type=int, default=1, help="Repeat transmission N times (default: 1)") +@click.option("--continuous", is_flag=True, help="Transmit continuously until Ctrl+C") +@click.option("--tx-delay", type=float, default=0, help="Delay between transmissions in seconds") +@click.option("--yes", "-y", is_flag=True, help="Skip safety confirmations") +@click.option("--verbose", "-v", is_flag=True, help="Verbose output") +@click.option("--quiet", "-q", is_flag=True, help="Suppress progress output") +def transmit( + device, + ident, + config_file, + sample_rate, + center_frequency, + gain, + bandwidth, + input_file, + legacy, + generate, + repeat, + continuous, + tx_delay, + yes, + verbose, + quiet, +): + """Transmit IQ samples from file using SDR device. + + \b + Examples: + utils transmit -d hackrf --generate lfm --continuous + utils transmit -d pluto -f 2.44G -g -10 -in recordings/rec_HackRF_2MHz_2025-12-01_15-36-21_80fc33f.sigmf-data + + """ + + # Load config file if specified + config = {} + if config_file: + config = load_yaml_config(config_file) + echo_verbose(f"Loaded config from: {config_file}", verbose) + + # Command-line args override config file + device = device or config.get("device") + ident = ident or config.get("ident") or config.get("serial") # Support legacy 'serial' in config + sample_rate = sample_rate or config.get("sample_rate") + center_frequency = center_frequency or config.get("center_frequency") + gain = gain or config.get("gain") + bandwidth = bandwidth or config.get("bandwidth") + input_file = input_file or config.get("input") + generate = generate or config.get("generate") + repeat = repeat if repeat != 1 else config.get("repeat", 1) + continuous = continuous or config.get("continuous", False) + tx_delay = tx_delay or config.get("tx_delay", 0) + + device, sample_rate, gain, bandwidth = select_params(device, sample_rate, gain, bandwidth, quiet, verbose) + + # Parse frequency + center_freq_hz = parse_frequency(center_frequency) + + # Validate TX gain + validate_tx_gain(device, gain) + + # Generate signal or load from file + recording = generate_recording(generate, input_file, sample_rate, verbose, legacy) + # Check sample rate mismatch + check_sample_rate_mismatch(recording, sample_rate, quiet) + + # Safety warnings for continuous mode + if continuous and not yes: + click.echo( + click.style("WARNING: ", fg="red", bold=True) + "Continuous transmission mode enabled\n" + "This will transmit indefinitely until stopped.\n" + "Ensure proper cooling and monitoring.", + err=True, + ) + if not click.confirm("Continue?", default=False): + click.echo("Transmission cancelled.") + return + + # Show transmission parameters + num_samples = len(recording.data[0]) if len(recording.data.shape) > 1 else len(recording.data) + echo_progress(f"Transmitting from {device.upper()}...", quiet) + echo_progress(f"Sample rate: {format_sample_rate(sample_rate)}", quiet) + echo_progress(f"Center frequency: {format_frequency(center_freq_hz)}", quiet) + echo_progress(f"TX gain: {gain} dB", quiet) + if bandwidth: + echo_progress(f"Bandwidth: {format_sample_rate(bandwidth)}", quiet) + + # Show signal source + if input_file: + echo_progress(f"Input: {os.path.basename(input_file)} ({num_samples} samples)", quiet) + else: + signal_type = generate if generate else "lfm" + echo_progress(f"Signal: Generated {signal_type.upper()} ({num_samples} samples)", quiet) + + if continuous: + echo_progress("Mode: Continuous (Ctrl+C to stop)", quiet) + elif repeat > 1: + echo_progress(f"Repeat: {repeat} times with {tx_delay}s delay", quiet) + + # Initialize device + echo_verbose("Initializing TX device...", verbose) + sdr = get_sdr_device(device, ident, True) + + # Set up Ctrl+C handler for continuous mode + stop_transmission = False + + def signal_handler(sig, frame): + nonlocal stop_transmission + stop_transmission = True + click.echo("\n\nStopping transmission...") + + if continuous: + signal.signal(signal.SIGINT, signal_handler) + + try: + # Initialize TX with parameters + sdr.init_tx( + sample_rate=sample_rate, center_frequency=center_freq_hz, gain=gain, channel=0 # Default to channel 0 + ) + + # Set bandwidth if supported (after init_tx) + if bandwidth is not None and hasattr(sdr, "set_tx_bandwidth"): + sdr.set_tx_bandwidth(bandwidth) + + # Transmission loop + if continuous: + echo_progress("\nTransmitting continuously... [Press Ctrl+C to stop]", quiet) + + transmission_count = 0 + while not stop_transmission: + sdr.tx_recording(recording) + transmission_count += 1 + + if verbose and transmission_count % 10 == 0: + echo_verbose(f"Transmitted {transmission_count} times", verbose) + + echo_progress(f"\nTransmitted {transmission_count} times total", quiet) + + else: + # Repeat mode or single transmission + repeated_transmission(sdr, recording, repeat, tx_delay, quiet, verbose) + + finally: + # Clean up device + echo_verbose("Closing TX device...", verbose) + if hasattr(sdr, "close"): + sdr.close() + + echo_progress("Transmission complete!", quiet) diff --git a/ria_toolkit_oss_cli/ria_toolkit_oss/view.py b/ria_toolkit_oss_cli/ria_toolkit_oss/view.py new file mode 100644 index 0000000..a092aab --- /dev/null +++ b/ria_toolkit_oss_cli/ria_toolkit_oss/view.py @@ -0,0 +1,418 @@ +"""View command - Create visualizations from recordings.""" + +import os +from pathlib import Path +from typing import Optional + +import click + +from utils.io.recording import from_npy, load_recording +from utils.view.view_signal import view_annotations, view_channels, view_sig +from utils.view.view_signal_simple import view_simple_sig + +from .common import echo_progress, echo_verbose, load_yaml_config + +# Map visualization types to their functions and parameters +VISUALIZATION_TYPES = { + "simple": { + "function": view_simple_sig, + "description": "Simple time-domain and spectrogram view", + "options": ["fast_mode", "compact_mode", "horizontal_mode", "constellation_mode", "labels_mode", "slice"], + }, + "full": { + "function": view_sig, + "description": "Full-featured plot with spectrogram, IQ, FFT, constellation, and metadata", + "options": [ + "plot_length", + "plot_spectrogram", + "iq", + "frequency", + "constellation", + "metadata", + "logo", + "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": []}, +} + + +def parse_slice(slice_str: str) -> tuple: + """Parse slice string in format 'start:end' or 'start:end:step'. + + Args: + slice_str: Slice string (e.g., "1000:5000" or "::2") + + Returns: + tuple: (start, end) or (start, end, step) + + Raises: + click.BadParameter: If slice format is invalid + """ + try: + parts = slice_str.split(":") + if len(parts) == 2: + start = int(parts[0]) if parts[0] else None + end = int(parts[1]) if parts[1] else None + return (start, end) + elif len(parts) == 3: + start = int(parts[0]) if parts[0] else None + end = int(parts[1]) if parts[1] else None + step = int(parts[2]) if parts[2] else None + return (start, end, step) + else: + raise ValueError("Slice must have 2 or 3 parts") + except (ValueError, IndexError): + raise click.BadParameter( + f"Invalid slice format: '{slice_str}'. " + f"Expected formats: 'start:end' or 'start:end:step' (e.g., '1000:5000' or '::2')" + ) + + +def parse_figsize(figsize_str: str) -> tuple: + """Parse figure size string in format 'WxH'. + + Args: + figsize_str: Figure size string (e.g., "10x6") + + Returns: + tuple: (width, height) in inches + + Raises: + click.BadParameter: If format is invalid + """ + try: + parts = figsize_str.lower().split("x") + if len(parts) != 2: + raise ValueError("Must have width and height") + width = float(parts[0]) + height = float(parts[1]) + if width <= 0 or height <= 0: + raise ValueError("Dimensions must be positive") + return (width, height) + except (ValueError, IndexError): + raise click.BadParameter( + f"Invalid figure size: '{figsize_str}'. " f"Expected format: 'WxH' (e.g., '10x6', '12.5x8')" + ) + + +def generate_output_path(input_path: str, output_path: Optional[str], format: str) -> str: + """Generate output path if not specified. + + Args: + input_path: Input file path + output_path: User-specified output path (or None) + format: Output format (png, pdf, svg, jpg) + + Returns: + str: Full output path + """ + if output_path: + return output_path + + # Auto-generate: input.sigmf -> input.png + input_path = Path(input_path) + + # Handle SigMF files specially (remove -data/-meta suffixes) + stem = input_path.stem + if stem.endswith("-data") or stem.endswith("-meta"): + stem = stem.rsplit("-", 1)[0] + + # Generate output filename + output_filename = f"{stem}.{format}" + return str(input_path.parent / output_filename) + + +def load_recording_with_legacy(input_path: str, legacy: bool, verbose: bool): + """Load recording, handling legacy NPY format. + + Args: + input_path: Path to input file + legacy: Whether to use legacy NPY loader + verbose: Verbose output + + Returns: + Recording object + + Raises: + click.ClickException: If loading fails + """ + try: + if legacy: + echo_verbose(f"Loading as legacy NPY format: {input_path}", verbose) + recording = from_npy(input_path, legacy=True) + else: + echo_verbose(f"Loading recording: {input_path}", verbose) + recording = load_recording(input_path) + + return recording + except FileNotFoundError: + raise click.ClickException(f"Input file not found: {input_path}") + except Exception as e: + raise click.ClickException(f"Error loading recording: {e}") + + +def get_view_output_path(should_save, overwrite, input, output, output_format): + if should_save: + output_path = generate_output_path(input, output, output_format) + + # Check if output exists + if os.path.exists(output_path) and not overwrite: + raise click.ClickException(f"Output file '{output_path}' already exists. " f"Use --overwrite to replace.") + else: + output_path = None + + return output_path + + +def print_metadata(recording, quiet): + # Print metadata to console + if not quiet: + click.echo("\nRecording Metadata:") + click.echo("-" * 40) + if recording._metadata: + for key, value in sorted(recording._metadata.items()): + # Format large numbers nicely + if isinstance(value, (int, float)) and abs(value) >= 1000: + if isinstance(value, float) and value >= 1e6: + click.echo(f" {key}: {value:,.0f}") + elif isinstance(value, float): + click.echo(f" {key}: {value:,.2f}") + else: + click.echo(f" {key}: {value:,}") + else: + click.echo(f" {key}: {value}") + else: + click.echo(" (no metadata)") + click.echo("-" * 40) + click.echo() + + +@click.command() +@click.argument("input", type=click.Path(exists=True)) +@click.option( + "--type", + "viz_type", + type=click.Choice(list(VISUALIZATION_TYPES.keys())), + default="simple", + show_default=True, + help="Visualization type", +) +@click.option("--output", type=click.Path(), help="Output file path (default: auto-generated)") +@click.option( + "--format", + "output_format", + type=click.Choice(["png", "pdf", "svg", "jpg"]), + default="png", + show_default=True, + help="Output format", +) +@click.option("--show", is_flag=True, help="Display interactive plot") +@click.option("--no-save", is_flag=True, help="Don't save file (only with --show)") +@click.option("--dpi", type=int, default=300, show_default=True, help="Output DPI (PNG only)") +@click.option("--figsize", type=str, help="Figure size in inches (e.g., '10x6')") +@click.option("--title", type=str, help="Custom plot title") +@click.option("--legacy", is_flag=True, help="Load input as legacy NPY format") +@click.option("--config", type=click.Path(exists=True), help="YAML config file") +# Type-specific options for 'simple' mode +@click.option("--fast", is_flag=True, help="[simple] Fast mode - reduced quality for speed") +@click.option("--compact", is_flag=True, help="[simple] Compact mode - minimal labels") +@click.option("--horizontal", is_flag=True, help="[simple] Horizontal layout") +@click.option("--constellation", is_flag=True, help="[simple] Show constellation plot") +@click.option("--labels", is_flag=True, help="[simple] Show detailed labels") +@click.option("--slice", type=str, help="[simple] Slice of signal (e.g., '1000:5000')") +# Type-specific options for 'full' mode +@click.option("--plot-length", type=int, help="[full] Number of samples to plot") +@click.option("--no-spectrogram", is_flag=True, help="[full] Disable spectrogram") +@click.option("--no-iq", is_flag=True, help="[full] Disable IQ plot") +@click.option("--no-frequency", is_flag=True, help="[full] Disable frequency plot") +@click.option("--no-constellation", is_flag=True, help="[full] Disable constellation") +@click.option("--no-metadata", is_flag=True, help="[full] Disable metadata display") +@click.option("--no-logo", is_flag=True, help="[full] Disable logo") +@click.option("--light", is_flag=True, help="[full/annotations] Use light theme") +@click.option("--spines", is_flag=True, help="[full] Show plot spines (borders)") +# Type-specific options for 'annotations' mode +@click.option("--channel", type=int, default=0, show_default=True, help="[annotations/channels] Channel to visualize") +# Common options +@click.option("--verbose", "-v", is_flag=True, help="Verbose output") +@click.option("--quiet", "-q", is_flag=True, help="Suppress output") +@click.option("--overwrite", is_flag=True, help="Overwrite existing output file") +def view( + input, + viz_type, + output, + output_format, + show, + no_save, + dpi, + figsize, + title, + legacy, + config, + fast, + compact, + horizontal, + constellation, + labels, + slice, + plot_length, + no_spectrogram, + no_iq, + no_frequency, + no_constellation, + no_metadata, + no_logo, + light, + spines, + channel, + verbose, + quiet, + overwrite, +): + """Create visualizations from recordings. + + INPUT is the recording file (SigMF, NPY, WAV, or MIDAS Blue format). + + \b + Examples: + # Basic visualization (saves to recording.png) + utils view recording.sigmf + \b + # Spectrogram with custom output + utils view capture.npy --output spec.png + \b + # Interactive display + utils view signal.npy --show --no-save + \b + # High-resolution PDF + utils view recording.blue --format pdf --dpi 600 + \b + # Simple mode with constellation + utils view qam.wav --type simple --constellation --labels + \b + # Full-featured plot + utils view capture.sigmf --type full --title "Lab Test" + \b + # Legacy NPY file + utils view old_capture.npy --legacy --type simple + """ + # Load config file if specified + if config: + _ = load_yaml_config(config) + # Config file overrides can be implemented here + echo_verbose(f"Loaded config from: {config}", verbose) + + # Determine if we should save + should_save = not no_save + + # Generate output path if needed + output_path = get_view_output_path(should_save, overwrite, input, output, output_format) + + # Load recording + echo_progress(f"Loading recording: {input}", quiet) + recording = load_recording_with_legacy(input, legacy, verbose) + + num_samples = len(recording.data[0]) if len(recording.data.shape) > 1 else len(recording.data) + echo_verbose(f"Loaded {num_samples:,} samples", verbose) + + # Print metadata to console + print_metadata(recording, quiet) + + # Get visualization info + viz_info = VISUALIZATION_TYPES[viz_type] + + # Type-specific parameters + # Note: view_simple_sig has 'saveplot' param, others don't + if viz_type == "simple": + params = { + "recording": recording, + "output_path": output_path or "temp.png", + "saveplot": should_save, + "fast_mode": fast, + "compact_mode": compact, + "horizontal_mode": horizontal, + "constellation_mode": constellation, + "labels_mode": labels, + } + + if slice: + parsed_slice = parse_slice(slice) + params["slice"] = parsed_slice + echo_verbose(f"Using slice: {parsed_slice}", verbose) + + elif viz_type == "full": + params = { + "recording": recording, + "output_path": output_path or "temp.png", + "dpi": dpi, + "plot_spectrogram": not no_spectrogram, + "iq": not no_iq, + "frequency": not no_frequency, + "constellation": not no_constellation, + "metadata": not no_metadata, + "logo": not no_logo, + "dark": not light, + "spines": spines, + } + if plot_length: + params["plot_length"] = plot_length + echo_verbose(f"Plot length: {plot_length:,} samples", verbose) + + elif viz_type == "annotations": + params = { + "recording": recording, + "output_path": output_path or "temp.png", + "channel": channel, + "dpi": dpi, + "dark": not light, + } + + elif viz_type == "channels": + params = { + "recording": recording, + "output_path": output_path or "temp.png", + } + + else: + raise click.ClickException(f"Unknown visualization type: {viz_type}") + + if not should_save and not show and viz_type != "simple": + raise click.ClickException(f"--no-save is not supported with --type {viz_type} (always saves)") + if title: + params["title"] = title + + # Generate visualization + viz_func = viz_info["function"] + echo_progress(f"Generating {viz_type} visualization...", quiet) + echo_verbose(f"Using function: {viz_func.__name__}", verbose) + + try: + _ = viz_func(**params) + + if should_save: + echo_progress(f"Saved: {output_path}", quiet) + + # Show file size + if verbose and os.path.exists(output_path): + size_kb = os.path.getsize(output_path) / 1024 + echo_verbose(f"File size: {size_kb:.1f} KB", verbose) + + # Show plot if requested + if show: + import matplotlib.pyplot as plt + + echo_verbose("Displaying plot...", verbose) + plt.show() + + except Exception as e: + raise click.ClickException(f"Error generating visualization: {e}") + + +# For CLI registration +__all__ = ["view"] diff --git a/src/ria_toolkit_oss/view/__init__.py b/src/ria_toolkit_oss/view/__init__.py new file mode 100644 index 0000000..f9db472 --- /dev/null +++ b/src/ria_toolkit_oss/view/__init__.py @@ -0,0 +1,12 @@ +""" +The package contains assorted plotting and report generation utilities to help visualize RIA components such as +recordings and radio datasets. +""" + +__all__ = [ + "view_annotations", + "view_channels", + "view_sig", +] + +from .view_signal import view_annotations, view_channels, view_sig diff --git a/src/ria_toolkit_oss/view/dataset.py b/src/ria_toolkit_oss/view/dataset.py new file mode 100644 index 0000000..ae41b7a --- /dev/null +++ b/src/ria_toolkit_oss/view/dataset.py @@ -0,0 +1,63 @@ +import os + +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.backends.backend_pdf import PdfPages + +from utils.io.recording import from_npy + + +def create_dataset_pdf(dataset_path, output_path, div=64, metadata_keys=None): + i = 0 + with PdfPages(output_path) as pdf: + for root, _, files in os.walk(dataset_path): + for file in files: + if file.endswith(".npy"): + i = i + 1 + + print(f"{i}/{len(files)}") + + full_path = os.path.join(root, file) + + recording = from_npy(full_path) + + samples = recording.data[0] + + metadata = recording.metadata + + if metadata_keys is not None: + metadata_to_print = {} + for key in metadata_keys: + metadata_to_print[key] = metadata.get(key, "None") + else: + metadata_to_print = metadata + + signal_length = len(samples) + nfft = max(2 ** int(np.log2(signal_length // div)), 64) + + dict_text = dict_text = "\n".join([f"{key}: {value}" for key, value in metadata_to_print.items()]) + + fig, axs = plt.subplots(2, 1, figsize=(10, 10), gridspec_kw={"height_ratios": [4, 1]}) + + # Create the spectrogram in the first subplot + axs[0].specgram(samples, NFFT=nfft, Fs=metadata["sample_rate"], cmap="twilight", noverlap=128) + axs[0].set_title(file) + axs[0].set_xlabel("Time (s)") + axs[0].set_ylabel("Frequency (Hz)") + # axs[0].colorbar(label='Intensity (dB)') + + # Adjust layout so that there's enough space for the second subplot (text) + plt.subplots_adjust(hspace=0.5) + + # Add the text in the second subplot + axs[1].text(0.1, 0.5, dict_text, ha="left", va="center", fontsize=10, color="black", wrap=True) + axs[1].axis("off") # Turn off axes for the text subplot + + # Save the figure (spectrogram and text) to the PDF + pdf.savefig(fig) + plt.close() + + +if __name__ == "__main__": + + create_dataset_pdf("/mnt/hddstorage/alec/qesa1_c4/nov15/low_mod2", "dataset.pdf") diff --git a/src/ria_toolkit_oss/view/recording.py b/src/ria_toolkit_oss/view/recording.py new file mode 100644 index 0000000..77d5282 --- /dev/null +++ b/src/ria_toolkit_oss/view/recording.py @@ -0,0 +1,192 @@ +import numpy as np +import plotly.graph_objects as go +import scipy.signal as signal +from plotly.graph_objs import Figure +from scipy.fft import fft, fftshift + +from utils.data import Recording + + +def spectrogram(rec: Recording, thumbnail: bool = False) -> Figure: + """Create a spectrogram for the recording. + + :param rec: Signal to plot. + :type rec: utils.data.Recording + :param thumbnail: Whether to return a small thumbnail version or full plot. + :type thumbnail: bool + + :return: Spectrogram, as a Plotly figure. + """ + complex_signal = rec.data[0] + sample_rate = int(rec.metadata.get("sample_rate", 1)) + plot_length = len(complex_signal) + + # Determine FFT size + if plot_length < 2000: + fft_size = 64 + elif plot_length < 10000: + fft_size = 256 + elif plot_length < 1000000: + fft_size = 1024 + else: + fft_size = 2048 + + frequencies, times, Sxx = signal.spectrogram( + complex_signal, + fs=sample_rate, + nfft=fft_size, + nperseg=fft_size, + noverlap=fft_size // 8, + scaling="density", + mode="complex", + return_onesided=False, + ) + + # Convert complex values to amplitude and then to log scale for visualization + Sxx_magnitude = np.abs(Sxx) + Sxx_log = np.log10(Sxx_magnitude + 1e-6) + + # Normalize spectrogram values between 0 and 1 for plotting + Sxx_log_shifted = Sxx_log - np.min(Sxx_log) + Sxx_log_norm = Sxx_log_shifted / np.max(Sxx_log_shifted) + + # Shift frequency bins and spectrogram rows so frequencies run from negative to positive + frequencies_shifted = np.fft.fftshift(frequencies) + Sxx_shifted = np.fft.fftshift(Sxx_log_norm, axes=0) + + fig = go.Figure( + data=go.Heatmap( + z=Sxx_shifted, + x=times / 1e6, + y=frequencies_shifted, + colorscale="Viridis", + zmin=0, + zmax=1, + reversescale=False, + showscale=False, + ) + ) + + if thumbnail: + fig.update_xaxes(showticklabels=False) + fig.update_yaxes(showticklabels=False) + fig.update_layout( + template="plotly_dark", + width=200, + height=100, + margin=dict(l=5, r=5, t=5, b=5), + xaxis=dict(scaleanchor=None), + yaxis=dict(scaleanchor=None), + ) + else: + fig.update_layout( + title="Spectrogram", + xaxis_title="Time [s]", + yaxis_title="Frequency [Hz]", + template="plotly_dark", + height=300, + width=800, + ) + + return fig + + +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 + + :return: Time series plot as a Plotly figure. + """ + complex_signal = rec.data[0] + sample_rate = int(rec.metadata.get("sample_rate", 1)) + plot_length = len(complex_signal) + t = np.arange(0, plot_length, 1) / sample_rate + + fig = go.Figure() + fig.add_trace(go.Scatter(x=t, y=complex_signal.real, mode="lines", name="I (In-phase)", line=dict(width=0.6))) + fig.add_trace(go.Scatter(x=t, y=complex_signal.imag, mode="lines", name="Q (Quadrature)", line=dict(width=0.6))) + + fig.update_layout( + title="IQ Time Series", + xaxis_title="Time [s]", + yaxis_title="Amplitude", + template="plotly_dark", + height=300, + width=800, + showlegend=True, + ) + + return fig + + +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 + + :return: Frequency spectrum as a Plotly figure. + """ + complex_signal = rec.data[0] + center_frequency = int(rec.metadata.get("center_frequency", 0)) + sample_rate = int(rec.metadata.get("sample_rate", 1)) + + epsilon = 1e-10 + spectrum = np.abs(fftshift(fft(complex_signal))) + freqs = np.linspace(-sample_rate / 2, sample_rate / 2, len(complex_signal)) + center_frequency + log_spectrum = np.log10(spectrum + epsilon) + scaled_log_spectrum = (log_spectrum - log_spectrum.min()) / (log_spectrum.max() - log_spectrum.min()) + + fig = go.Figure() + fig.add_trace(go.Scatter(x=freqs, y=scaled_log_spectrum, mode="lines", name="Spectrum", line=dict(width=0.4))) + + fig.update_layout( + title="Frequency Spectrum", + xaxis_title="Frequency [Hz]", + yaxis_title="Magnitude", + yaxis_type="log", + template="plotly_dark", + height=300, + width=800, + showlegend=False, + ) + + return fig + + +def constellation(rec: Recording) -> Figure: + """Create a constellation plot from the recording. + + :param rec: Input signal to plot. + :type rec: utils.data.Recording + + :return: Constellation as a Plotly figure. + """ + complex_signal = rec.data[0] + + # Downsample the IQ samples to a target number of points + # This reduces the amount of data plotted, improving performance and interactivity + # without losing significant detail in the constellation visualization. + target_number_of_points = 5000 + step = max(1, len(complex_signal) // target_number_of_points) + i_ds = complex_signal.real[::step] + q_ds = complex_signal.imag[::step] + + fig = go.Figure() + fig.add_trace(go.Scatter(x=i_ds, y=q_ds, mode="lines", name="Constellation", line=dict(width=0.2))) + + fig.update_layout( + title="Constellation", + xaxis_title="In-phase (I)", + yaxis_title="Quadrature (Q)", + template="plotly_dark", + height=400, + width=400, + showlegend=False, + xaxis=dict(range=[-1.1, 1.1]), + yaxis=dict(range=[-1.1, 1.1]), + ) + + return fig