"""Configuration for chuk-mcp-physics server.
This module handles configuration loading from YAML files and environment variables.
Configuration sources (in order of precedence):
1. Environment variables
2. YAML configuration file (physics.yaml)
3. Default values
"""
import logging
import os
from enum import Enum
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# Try to import yaml, but don't fail if not available
try:
import yaml # type: ignore[import-untyped]
YAML_AVAILABLE = True
except ImportError:
YAML_AVAILABLE = False
logger.debug("PyYAML not available, using environment variables only")
def load_yaml_config() -> dict[str, Any]:
"""Load configuration from YAML file if it exists.
Looks for physics.yaml in:
1. Current working directory
2. User's home directory (~/.config/chuk-mcp-physics/)
3. Environment variable PHYSICS_CONFIG_PATH
Returns:
Dictionary of configuration values, or empty dict if no file found
"""
if not YAML_AVAILABLE:
return {}
config_paths = [
Path.cwd() / "physics.yaml",
Path.home() / ".config" / "chuk-mcp-physics" / "physics.yaml",
]
# Add path from environment variable if set
if env_path := os.getenv("PHYSICS_CONFIG_PATH"):
config_paths.insert(0, Path(env_path))
for config_path in config_paths:
if config_path.exists():
try:
with open(config_path) as f:
config = yaml.safe_load(f)
logger.info(f"Loaded configuration from {config_path}")
return config or {}
except Exception as e:
logger.warning(f"Failed to load config from {config_path}: {e}")
logger.debug("No YAML config file found, using environment variables and defaults")
return {}
class ProviderType(str, Enum):
"""Available provider types."""
ANALYTIC = "analytic"
RAPIER = "rapier"
# Load YAML config once at module import
_yaml_config = load_yaml_config()
class ProviderConfig:
"""Configuration for provider selection.
Providers can be configured globally or per-tool via:
1. Environment variables (highest priority)
2. YAML configuration file
3. Default values
Example YAML:
default_provider: analytic
providers:
projectile_motion: analytic
simulations: rapier
"""
# Default provider for all tools
# Priority: env var > YAML > hardcoded default
DEFAULT_PROVIDER = os.getenv(
"PHYSICS_PROVIDER",
_yaml_config.get("default_provider", ProviderType.ANALYTIC.value),
)
# Per-tool provider overrides
_providers_yaml = _yaml_config.get("providers", {})
# Analytic calculations can only use analytic provider
PROJECTILE_MOTION_PROVIDER = ProviderType.ANALYTIC.value
COLLISION_CHECK_PROVIDER = ProviderType.ANALYTIC.value
FORCE_CALCULATION_PROVIDER = ProviderType.ANALYTIC.value
KINETIC_ENERGY_PROVIDER = ProviderType.ANALYTIC.value
MOMENTUM_PROVIDER = ProviderType.ANALYTIC.value
# Simulation tools use configured provider
SIMULATION_PROVIDER = os.getenv(
"PHYSICS_SIMULATION_PROVIDER",
_providers_yaml.get("simulations", DEFAULT_PROVIDER),
)
class RapierConfig:
"""Configuration for Rapier service provider.
Loads from (in order of precedence):
1. Environment variables
2. YAML config rapier section
3. Default values
"""
_rapier_yaml = _yaml_config.get("rapier", {})
# Rapier service URL
# Default depends on environment:
# - Fly.io deployment: use public service
# - Local development: use localhost
_default_service_url = (
"https://rapier.chukai.io" if os.getenv("FLY_APP_NAME") else "http://localhost:9000"
)
SERVICE_URL = os.getenv(
"RAPIER_SERVICE_URL",
_rapier_yaml.get("service_url", _default_service_url),
)
# Request timeout in seconds
TIMEOUT = float(
os.getenv(
"RAPIER_TIMEOUT",
str(_rapier_yaml.get("timeout", 30.0)),
)
)
# Retry configuration
MAX_RETRIES = int(
os.getenv(
"RAPIER_MAX_RETRIES",
str(_rapier_yaml.get("max_retries", 3)),
)
)
RETRY_DELAY = float(
os.getenv(
"RAPIER_RETRY_DELAY",
str(_rapier_yaml.get("retry_delay", 1.0)),
)
)
class SimulationLimits:
"""Safety limits for physics simulations.
These limits prevent timeouts, instabilities, and resource exhaustion.
They can be overridden via environment variables if needed.
"""
# General limits
MAX_STEPS_PER_CALL = int(os.getenv("PHYSICS_MAX_STEPS", "10000"))
MAX_BODIES_PER_SIM = int(os.getenv("PHYSICS_MAX_BODIES", "1000"))
MAX_DURATION = float(os.getenv("PHYSICS_MAX_DURATION", "60.0"))
MAX_TRAJECTORY_FRAMES = int(os.getenv("PHYSICS_MAX_TRAJECTORY_FRAMES", "10000"))
# Timestep constraints (seconds)
MIN_DT = float(os.getenv("PHYSICS_MIN_DT", "0.001"))
MAX_DT = float(os.getenv("PHYSICS_MAX_DT", "0.1"))
RECOMMENDED_DT = 0.016 # 60 FPS
# Public service limits (stricter for shared infrastructure)
PUBLIC_MAX_STEPS = 5_000
PUBLIC_MAX_BODIES = 100
PUBLIC_MAX_CONCURRENT_SIMS = 10
PUBLIC_TIMEOUT = 30.0