Source code for scgo.utils.run_tracking

"""Run tracking and metadata management for SCGO campaigns.

This module provides functions for generating run IDs, saving and loading
run metadata, and managing run-specific directory structures.
"""

from __future__ import annotations

import json
import os
from dataclasses import asdict, dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

from scgo.utils.helpers import ensure_directory_exists, get_cluster_formula
from scgo.utils.logging import get_logger
from scgo.utils.ts_provenance import ts_output_provenance


[docs] class RunMetadataJSONEncoder(json.JSONEncoder): """JSON encoder: ``type`` objects become their ``__name__`` (for params snapshots)."""
[docs] def default(self, obj: Any) -> Any: if isinstance(obj, type): return obj.__name__ return super().default(obj)
[docs] @dataclass class RunMetadata: """Metadata for a single run.""" run_id: str timestamp: str composition: list[str] | None = None formula: str | None = None # Chemical formula for quick filtering params: dict[str, Any] | None = None
[docs] def to_dict(self) -> dict[str, Any]: """Convert metadata to dictionary.""" return asdict(self)
[docs] @classmethod def from_dict(cls, data: dict[str, Any]) -> RunMetadata: """Create metadata from dictionary (ignores TS-parity provenance keys).""" return cls( run_id=data["run_id"], timestamp=data["timestamp"], composition=data.get("composition"), formula=data.get("formula"), params=data.get("params"), )
[docs] def generate_run_id() -> str: """Generate timestamp-based run ID with microsecond granularity. Returns: Run ID in format: run_YYYYMMDD_HHMMSS_ffffff Example: "run_20250124_143022_123456" """ now = datetime.now(UTC) timestamp = now.strftime("%Y%m%d_%H%M%S") microseconds = now.microsecond return f"run_{timestamp}_{microseconds:06d}"
[docs] def ensure_run_id(run_id: str | None, verbosity: int = 0, logger=None) -> str: """Ensure a run_id exists, generating one if needed and logging if appropriate. This helper consolidates the common pattern of generating a run_id if None and logging it when verbosity is sufficient. Args: run_id: Existing run_id or None to generate a new one. verbosity: Logging verbosity level (0=quiet, 1=normal, 2=debug, 3=trace). logger: Optional logger instance. If None, will create one if needed. Returns: Run ID (existing or newly generated). """ if run_id is None: run_id = generate_run_id() if verbosity >= 1: if logger is None: logger = get_logger(__name__) logger.info(f"Generated run ID: {run_id}") return run_id
[docs] def save_run_metadata( run_dir: str, run_id: str, metadata: dict[str, Any] | None = None, ) -> None: """Save run metadata to metadata.json file. Args: run_dir: Directory where metadata file will be saved. run_id: Run ID for this run. metadata: Optional dictionary of additional metadata to store. Common keys: composition, params, etc. """ ensure_directory_exists(run_dir) # Extract composition and compute formula if available composition = metadata.get("composition") if metadata else None formula = metadata.get("formula") if metadata else None if composition and not formula: formula = get_cluster_formula(composition) metadata_obj = RunMetadata( run_id=run_id, timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), composition=composition, formula=formula, params=metadata.get("params") if metadata else None, ) payload = {**ts_output_provenance(), **metadata_obj.to_dict()} metadata_file = os.path.join(run_dir, "metadata.json") with open(metadata_file, "w") as f: json.dump(payload, f, indent=2, cls=RunMetadataJSONEncoder)
[docs] def load_run_metadata(run_dir: str) -> RunMetadata | None: """Load run metadata from metadata.json file. Args: run_dir: Directory containing metadata.json file. Returns: RunMetadata object if metadata file exists and is valid, None otherwise. """ metadata_file = os.path.join(run_dir, "metadata.json") if not os.path.exists(metadata_file): return None try: with open(metadata_file) as f: data = json.load(f) return RunMetadata.from_dict(data) except (json.JSONDecodeError, KeyError, TypeError) as e: logger = get_logger(__name__) logger.warning(f"Failed to load metadata from {metadata_file}: {e}") return None
[docs] def get_run_directories(base_output_dir: str) -> list[str]: """Get list of all run directories in base output directory. Args: base_output_dir: Base directory to search for run_* subdirectories. Returns: List of run directory paths (full paths), sorted by name. """ if not os.path.exists(base_output_dir): return [] run_dirs = [ os.path.join(base_output_dir, item) for item in os.listdir(base_output_dir) if ( item.startswith("run_") and os.path.isdir(os.path.join(base_output_dir, item)) and get_run_id_from_dir(item) is not None ) ] return sorted(run_dirs)
[docs] def resolve_run_id_from_db_path( db_path: str | Path, *, base_dir: str | Path | None = None, ) -> str: """Resolve GO run ID from a database path (``run_*`` segment when present).""" db_path_str = os.path.abspath(str(db_path)) if base_dir is not None: base_s = os.path.abspath(str(base_dir)) try: rel = os.path.relpath(db_path_str, base_s) parts = rel.split(os.sep) except ValueError: parts = Path(db_path_str).parts else: parts = Path(db_path_str).parts for part in parts: resolved = get_run_id_from_dir(part) if resolved is not None: return resolved if part.startswith("run_"): return part parent_name = Path(db_path_str).parent.name resolved = get_run_id_from_dir(parent_name) if resolved is not None: return resolved if parent_name.startswith("run_"): return parent_name basename = os.path.basename(db_path_str) logger = get_logger(__name__) logger.warning( "Could not resolve run_id from path %s; using database basename %r as fallback", db_path, basename, ) return basename
[docs] def get_run_id_from_dir(run_dir: str) -> str | None: """Extract run ID from directory name. Args: run_dir: Directory path (may be full path or just name). Returns: Run ID if directory name matches pattern, None otherwise. """ dir_name = os.path.basename(run_dir) # Format: run_YYYYMMDD_HHMMSS_ffffff (26 characters: 4 + 8 + 1 + 6 + 1 + 6) if dir_name.startswith("run_") and len(dir_name) == 26: # Additional validation: should match pattern run_YYYYMMDD_HHMMSS_ffffff parts = dir_name.split("_") if ( len(parts) == 4 and len(parts[1]) == 8 and len(parts[2]) == 6 and len(parts[3]) == 6 ): return dir_name return None