scripts/detect_resources.py

#!/usr/bin/env python3
"""
System Resource Detection Script

Detects available compute resources including CPU, GPU, memory, and disk space.
Outputs a JSON file that Claude Code can use to make informed decisions about
computational approaches (e.g., whether to use Dask, Zarr, Joblib, etc.).

Supports: macOS, Linux, Windows
GPU Detection: NVIDIA (CUDA), AMD (ROCm), Apple Silicon (Metal)
"""

import json
import os
import platform
import psutil
import subprocess
import sys
from pathlib import Path
from typing import Dict, List, Any, Optional


def get_cpu_info() -> Dict[str, Any]:
    """Detect CPU information."""
    cpu_info = {
        "physical_cores": psutil.cpu_count(logical=False),
        "logical_cores": psutil.cpu_count(logical=True),
        "max_frequency_mhz": None,
        "architecture": platform.machine(),
        "processor": platform.processor(),
    }

    # Get CPU frequency if available
    try:
        freq = psutil.cpu_freq()
        if freq:
            cpu_info["max_frequency_mhz"] = freq.max
            cpu_info["current_frequency_mhz"] = freq.current
    except Exception:
        pass

    return cpu_info


def get_memory_info() -> Dict[str, Any]:
    """Detect memory information."""
    mem = psutil.virtual_memory()
    swap = psutil.swap_memory()

    return {
        "total_gb": round(mem.total / (1024**3), 2),
        "available_gb": round(mem.available / (1024**3), 2),
        "used_gb": round(mem.used / (1024**3), 2),
        "percent_used": mem.percent,
        "swap_total_gb": round(swap.total / (1024**3), 2),
        "swap_available_gb": round((swap.total - swap.used) / (1024**3), 2),
    }


def get_disk_info(path: str = None) -> Dict[str, Any]:
    """Detect disk space information for working directory or specified path."""
    if path is None:
        path = os.getcwd()

    try:
        disk = psutil.disk_usage(path)
        return {
            "path": path,
            "total_gb": round(disk.total / (1024**3), 2),
            "available_gb": round(disk.free / (1024**3), 2),
            "used_gb": round(disk.used / (1024**3), 2),
            "percent_used": disk.percent,
        }
    except Exception as e:
        return {
            "path": path,
            "error": str(e),
        }


def detect_nvidia_gpus() -> List[Dict[str, Any]]:
    """Detect NVIDIA GPUs using nvidia-smi."""
    gpus = []

    try:
        # Try to run nvidia-smi
        result = subprocess.run(
            ["nvidia-smi", "--query-gpu=index,name,memory.total,memory.free,driver_version,compute_cap",
             "--format=csv,noheader,nounits"],
            capture_output=True,
            text=True,
            timeout=5
        )

        if result.returncode == 0:
            for line in result.stdout.strip().split('\n'):
                if line:
                    parts = [p.strip() for p in line.split(',')]
                    if len(parts) >= 6:
                        gpus.append({
                            "index": int(parts[0]),
                            "name": parts[1],
                            "memory_total_mb": float(parts[2]),
                            "memory_free_mb": float(parts[3]),
                            "driver_version": parts[4],
                            "compute_capability": parts[5],
                            "type": "NVIDIA",
                            "backend": "CUDA"
                        })
    except (subprocess.TimeoutExpired, FileNotFoundError, Exception):
        pass

    return gpus


def detect_amd_gpus() -> List[Dict[str, Any]]:
    """Detect AMD GPUs using rocm-smi."""
    gpus = []

    try:
        # Try to run rocm-smi
        result = subprocess.run(
            ["rocm-smi", "--showid", "--showmeminfo", "vram"],
            capture_output=True,
            text=True,
            timeout=5
        )

        if result.returncode == 0:
            # Parse rocm-smi output (basic parsing, may need refinement)
            lines = result.stdout.strip().split('\n')
            gpu_index = 0
            for line in lines:
                if 'GPU' in line and 'DID' in line:
                    gpus.append({
                        "index": gpu_index,
                        "name": "AMD GPU",
                        "type": "AMD",
                        "backend": "ROCm",
                        "info": line.strip()
                    })
                    gpu_index += 1
    except (subprocess.TimeoutExpired, FileNotFoundError, Exception):
        pass

    return gpus


def detect_apple_silicon_gpu() -> Optional[Dict[str, Any]]:
    """Detect Apple Silicon GPU (M1/M2/M3/etc.)."""
    if platform.system() != "Darwin":
        return None

    try:
        # Check if running on Apple Silicon
        result = subprocess.run(
            ["sysctl", "-n", "machdep.cpu.brand_string"],
            capture_output=True,
            text=True,
            timeout=5
        )

        cpu_brand = result.stdout.strip()

        # Check for Apple Silicon (M1, M2, M3, etc.)
        if "Apple" in cpu_brand and any(chip in cpu_brand for chip in ["M1", "M2", "M3", "M4"]):
            # Get GPU core count if possible
            gpu_info = {
                "name": cpu_brand,
                "type": "Apple Silicon",
                "backend": "Metal",
                "unified_memory": True,  # Apple Silicon uses unified memory
            }

            # Try to get GPU core information
            try:
                result = subprocess.run(
                    ["system_profiler", "SPDisplaysDataType"],
                    capture_output=True,
                    text=True,
                    timeout=10
                )

                # Parse GPU core info from system_profiler
                for line in result.stdout.split('\n'):
                    if 'Chipset Model' in line:
                        gpu_info["chipset"] = line.split(':')[1].strip()
                    elif 'Total Number of Cores' in line:
                        try:
                            cores = line.split(':')[1].strip()
                            gpu_info["gpu_cores"] = cores
                        except:
                            pass
            except Exception:
                pass

            return gpu_info
    except Exception:
        pass

    return None


def get_gpu_info() -> Dict[str, Any]:
    """Detect all available GPUs."""
    gpu_info = {
        "nvidia_gpus": detect_nvidia_gpus(),
        "amd_gpus": detect_amd_gpus(),
        "apple_silicon": detect_apple_silicon_gpu(),
        "total_gpus": 0,
        "available_backends": []
    }

    # Count total GPUs and available backends
    if gpu_info["nvidia_gpus"]:
        gpu_info["total_gpus"] += len(gpu_info["nvidia_gpus"])
        gpu_info["available_backends"].append("CUDA")

    if gpu_info["amd_gpus"]:
        gpu_info["total_gpus"] += len(gpu_info["amd_gpus"])
        gpu_info["available_backends"].append("ROCm")

    if gpu_info["apple_silicon"]:
        gpu_info["total_gpus"] += 1
        gpu_info["available_backends"].append("Metal")

    return gpu_info


def get_os_info() -> Dict[str, Any]:
    """Get operating system information."""
    return {
        "system": platform.system(),
        "release": platform.release(),
        "version": platform.version(),
        "machine": platform.machine(),
        "python_version": platform.python_version(),
    }


def detect_all_resources(output_path: str = None) -> Dict[str, Any]:
    """
    Detect all system resources and save to JSON.

    Args:
        output_path: Optional path to save JSON. Defaults to .claude_resources.json in cwd.

    Returns:
        Dictionary containing all resource information.
    """
    if output_path is None:
        output_path = os.path.join(os.getcwd(), ".claude_resources.json")

    resources = {
        "timestamp": __import__("datetime").datetime.now().isoformat(),
        "os": get_os_info(),
        "cpu": get_cpu_info(),
        "memory": get_memory_info(),
        "disk": get_disk_info(),
        "gpu": get_gpu_info(),
    }

    # Add computational recommendations
    resources["recommendations"] = generate_recommendations(resources)

    # Save to JSON file
    with open(output_path, 'w') as f:
        json.dump(resources, f, indent=2)

    return resources


def generate_recommendations(resources: Dict[str, Any]) -> Dict[str, Any]:
    """
    Generate computational approach recommendations based on available resources.
    """
    recommendations = {
        "parallel_processing": {},
        "memory_strategy": {},
        "gpu_acceleration": {},
        "large_data_handling": {}
    }

    # CPU recommendations
    cpu_cores = resources["cpu"]["logical_cores"]
    if cpu_cores >= 8:
        recommendations["parallel_processing"]["strategy"] = "high_parallelism"
        recommendations["parallel_processing"]["suggested_workers"] = max(cpu_cores - 2, 1)
        recommendations["parallel_processing"]["libraries"] = ["joblib", "multiprocessing", "dask"]
    elif cpu_cores >= 4:
        recommendations["parallel_processing"]["strategy"] = "moderate_parallelism"
        recommendations["parallel_processing"]["suggested_workers"] = max(cpu_cores - 1, 1)
        recommendations["parallel_processing"]["libraries"] = ["joblib", "multiprocessing"]
    else:
        recommendations["parallel_processing"]["strategy"] = "sequential"
        recommendations["parallel_processing"]["note"] = "Limited cores, prefer sequential processing"

    # Memory recommendations
    available_memory_gb = resources["memory"]["available_gb"]
    total_memory_gb = resources["memory"]["total_gb"]

    if available_memory_gb < 4:
        recommendations["memory_strategy"]["strategy"] = "memory_constrained"
        recommendations["memory_strategy"]["libraries"] = ["zarr", "dask", "h5py"]
        recommendations["memory_strategy"]["note"] = "Use out-of-core processing for large datasets"
    elif available_memory_gb < 16:
        recommendations["memory_strategy"]["strategy"] = "moderate_memory"
        recommendations["memory_strategy"]["libraries"] = ["dask", "zarr"]
        recommendations["memory_strategy"]["note"] = "Consider chunking for datasets > 2GB"
    else:
        recommendations["memory_strategy"]["strategy"] = "memory_abundant"
        recommendations["memory_strategy"]["note"] = "Can load most datasets into memory"

    # GPU recommendations
    gpu_info = resources["gpu"]
    if gpu_info["total_gpus"] > 0:
        recommendations["gpu_acceleration"]["available"] = True
        recommendations["gpu_acceleration"]["backends"] = gpu_info["available_backends"]

        if "CUDA" in gpu_info["available_backends"]:
            recommendations["gpu_acceleration"]["suggested_libraries"] = [
                "pytorch", "tensorflow", "jax", "cupy", "rapids"
            ]
        elif "Metal" in gpu_info["available_backends"]:
            recommendations["gpu_acceleration"]["suggested_libraries"] = [
                "pytorch-mps", "tensorflow-metal", "jax-metal"
            ]
        elif "ROCm" in gpu_info["available_backends"]:
            recommendations["gpu_acceleration"]["suggested_libraries"] = [
                "pytorch-rocm", "tensorflow-rocm"
            ]
    else:
        recommendations["gpu_acceleration"]["available"] = False
        recommendations["gpu_acceleration"]["note"] = "No GPU detected, use CPU-based libraries"

    # Large data handling recommendations
    disk_available_gb = resources["disk"]["available_gb"]
    if disk_available_gb < 10:
        recommendations["large_data_handling"]["strategy"] = "disk_constrained"
        recommendations["large_data_handling"]["note"] = "Limited disk space, use streaming or compression"
    elif disk_available_gb < 100:
        recommendations["large_data_handling"]["strategy"] = "moderate_disk"
        recommendations["large_data_handling"]["libraries"] = ["zarr", "h5py", "parquet"]
    else:
        recommendations["large_data_handling"]["strategy"] = "disk_abundant"
        recommendations["large_data_handling"]["note"] = "Sufficient space for large intermediate files"

    return recommendations


def main():
    """Main entry point for CLI usage."""
    import argparse

    parser = argparse.ArgumentParser(
        description="Detect system resources for scientific computing"
    )
    parser.add_argument(
        "-o", "--output",
        default=".claude_resources.json",
        help="Output JSON file path (default: .claude_resources.json)"
    )
    parser.add_argument(
        "-v", "--verbose",
        action="store_true",
        help="Print resources to stdout"
    )

    args = parser.parse_args()

    print("šŸ” Detecting system resources...")
    resources = detect_all_resources(args.output)

    print(f"āœ… Resources detected and saved to: {args.output}")

    if args.verbose:
        print("\n" + "="*60)
        print(json.dumps(resources, indent=2))
        print("="*60)

    # Print summary
    print("\nšŸ“Š Resource Summary:")
    print(f"  OS: {resources['os']['system']} {resources['os']['release']}")
    print(f"  CPU: {resources['cpu']['logical_cores']} cores ({resources['cpu']['physical_cores']} physical)")
    print(f"  Memory: {resources['memory']['total_gb']} GB total, {resources['memory']['available_gb']} GB available")
    print(f"  Disk: {resources['disk']['total_gb']} GB total, {resources['disk']['available_gb']} GB available")

    if resources['gpu']['total_gpus'] > 0:
        print(f"  GPU: {resources['gpu']['total_gpus']} detected ({', '.join(resources['gpu']['available_backends'])})")
    else:
        print("  GPU: None detected")

    print("\nšŸ’” Recommendations:")
    recs = resources['recommendations']
    print(f"  Parallel Processing: {recs['parallel_processing'].get('strategy', 'N/A')}")
    print(f"  Memory Strategy: {recs['memory_strategy'].get('strategy', 'N/A')}")
    print(f"  GPU Acceleration: {'Available' if recs['gpu_acceleration'].get('available') else 'Not Available'}")


if __name__ == "__main__":
    main()
← Back to get-available-resources