"""Layer 4 — Physics Closure & Simulation Authority. Responsible for: - Governing equation selection (structural, thermal, fluid) - Boundary condition enforcement - Safety factor calculation and validation - Failure mode completeness analysis - Simulation binding (simulations are binding, not illustrative) """ import hashlib import math import uuid from abc import ABC, abstractmethod from dataclasses import dataclass from enum import Enum from typing import Any from pydantic import BaseModel, Field from fusionagi._logger import logger class PhysicsUnderdefinedError(Exception): """Failure state: physics not fully defined.""" def __init__(self, message: str, missing_data: list[str] | None = None): self.missing_data = missing_data or [] super().__init__(message) class ProofResult(str, Enum): """Result of physics validation.""" PROOF = "proof" PHYSICS_UNDEFINED = "physics_underdefined" VALIDATION_FAILED = "validation_failed" class PhysicsProof(BaseModel): """Binding simulation proof reference.""" proof_id: str = Field(...) governing_equations: str | None = Field(default=None) boundary_conditions_ref: str | None = Field(default=None) safety_factor: float | None = Field(default=None) failure_modes_covered: list[str] = Field(default_factory=list) metadata: dict[str, Any] = Field(default_factory=dict) validation_status: str = Field(default="validated") warnings: list[str] = Field(default_factory=list) class PhysicsAuthorityInterface(ABC): """ Abstract interface for physics validation. Governing equation selection, boundary condition enforcement, safety factor declaration, failure-mode completeness. Simulations are binding, not illustrative. """ @abstractmethod def validate_physics( self, design_ref: str, load_cases: list[dict[str, Any]] | None = None, **kwargs: Any, ) -> PhysicsProof | None: """ Validate physics for design; return Proof or None (PhysicsUnderdefined). Raises PhysicsUnderdefinedError if required data missing. """ ... # Common material properties database (simplified) MATERIAL_PROPERTIES: dict[str, dict[str, float]] = { "aluminum_6061": { "yield_strength_mpa": 276, "ultimate_strength_mpa": 310, "elastic_modulus_gpa": 68.9, "density_kg_m3": 2700, "poisson_ratio": 0.33, "thermal_expansion_per_c": 23.6e-6, "max_service_temp_c": 150, }, "steel_4140": { "yield_strength_mpa": 655, "ultimate_strength_mpa": 1020, "elastic_modulus_gpa": 205, "density_kg_m3": 7850, "poisson_ratio": 0.29, "thermal_expansion_per_c": 12.3e-6, "max_service_temp_c": 400, }, "titanium_ti6al4v": { "yield_strength_mpa": 880, "ultimate_strength_mpa": 950, "elastic_modulus_gpa": 113.8, "density_kg_m3": 4430, "poisson_ratio": 0.34, "thermal_expansion_per_c": 8.6e-6, "max_service_temp_c": 350, }, "pla_plastic": { "yield_strength_mpa": 60, "ultimate_strength_mpa": 65, "elastic_modulus_gpa": 3.5, "density_kg_m3": 1240, "poisson_ratio": 0.36, "thermal_expansion_per_c": 68e-6, "max_service_temp_c": 55, }, "abs_plastic": { "yield_strength_mpa": 40, "ultimate_strength_mpa": 44, "elastic_modulus_gpa": 2.3, "density_kg_m3": 1050, "poisson_ratio": 0.35, "thermal_expansion_per_c": 90e-6, "max_service_temp_c": 85, }, } # Standard failure modes to check STANDARD_FAILURE_MODES = [ "yield_failure", "ultimate_failure", "buckling", "fatigue", "creep", "thermal_distortion", "vibration_resonance", ] @dataclass class LoadCaseResult: """Result of validating a single load case.""" load_case_id: str max_stress_mpa: float safety_factor: float passed: bool failure_mode: str | None = None details: dict[str, Any] | None = None class PhysicsAuthority(PhysicsAuthorityInterface): """ Physics validation authority with actual validation logic. Features: - Material property validation - Load case analysis - Safety factor calculation - Failure mode coverage analysis - Governing equation selection based on load types """ def __init__( self, required_safety_factor: float = 2.0, material_db: dict[str, dict[str, float]] | None = None, custom_failure_modes: list[str] | None = None, ): """ Initialize the PhysicsAuthority. Args: required_safety_factor: Minimum required safety factor (default 2.0). material_db: Custom material properties database. custom_failure_modes: Additional failure modes to check. """ self._required_sf = required_safety_factor self._materials = material_db or MATERIAL_PROPERTIES self._failure_modes = list(STANDARD_FAILURE_MODES) if custom_failure_modes: self._failure_modes.extend(custom_failure_modes) def validate_physics( self, design_ref: str, load_cases: list[dict[str, Any]] | None = None, material: str | None = None, dimensions: dict[str, float] | None = None, boundary_conditions: dict[str, Any] | None = None, **kwargs: Any, ) -> PhysicsProof | None: """ Validate physics for a design. Args: design_ref: Reference to the design being validated. load_cases: List of load cases to validate against. material: Material identifier (must be in material database). dimensions: Key dimensions for stress calculation. boundary_conditions: Boundary condition specification. **kwargs: Additional parameters. Returns: PhysicsProof if validation passes, None if physics underdefined. Raises: PhysicsUnderdefinedError: If critical data is missing. """ missing_data = [] if not design_ref: missing_data.append("design_ref") if not material: missing_data.append("material") if not load_cases: missing_data.append("load_cases") if missing_data: raise PhysicsUnderdefinedError( f"Physics validation requires: {', '.join(missing_data)}", missing_data=missing_data, ) # Get material properties mat_props = self._materials.get(material.lower().replace(" ", "_")) if not mat_props: raise PhysicsUnderdefinedError( f"Unknown material: {material}. Available: {list(self._materials.keys())}", missing_data=["material_properties"], ) # Validate each load case load_case_results: list[LoadCaseResult] = [] min_safety_factor = float("inf") warnings: list[str] = [] failure_modes_covered: list[str] = [] for lc in load_cases: result = self._validate_load_case(lc, mat_props, dimensions) load_case_results.append(result) if result.safety_factor < min_safety_factor: min_safety_factor = result.safety_factor if not result.passed: warnings.append( f"Load case '{result.load_case_id}' failed: {result.failure_mode}" ) # Track failure modes analyzed if result.failure_mode and result.failure_mode not in failure_modes_covered: failure_modes_covered.append(result.failure_mode) # Determine governing equations based on load types governing_equations = self._select_governing_equations(load_cases) # Check minimum required failure modes required_modes = ["yield_failure", "ultimate_failure"] for mode in required_modes: if mode not in failure_modes_covered: failure_modes_covered.append(mode) # Basic checks are always done # Generate proof ID based on inputs proof_hash = hashlib.sha256( f"{design_ref}:{material}:{load_cases}".encode() ).hexdigest()[:16] proof_id = f"proof_{design_ref}_{proof_hash}" # Determine validation status validation_status = "validated" if min_safety_factor < self._required_sf: validation_status = "insufficient_safety_factor" warnings.append( f"Safety factor {min_safety_factor:.2f} < required {self._required_sf}" ) if any(not r.passed for r in load_case_results): validation_status = "load_case_failure" logger.info( "Physics validation completed", extra={ "design_ref": design_ref, "material": material, "min_sf": min_safety_factor, "status": validation_status, "num_load_cases": len(load_cases), }, ) return PhysicsProof( proof_id=proof_id, governing_equations=governing_equations, boundary_conditions_ref=str(boundary_conditions) if boundary_conditions else None, safety_factor=min_safety_factor if min_safety_factor != float("inf") else None, failure_modes_covered=failure_modes_covered, metadata={ "material": material, "material_properties": mat_props, "load_case_results": [ { "id": r.load_case_id, "max_stress_mpa": r.max_stress_mpa, "sf": r.safety_factor, "passed": r.passed, } for r in load_case_results ], "required_safety_factor": self._required_sf, }, validation_status=validation_status, warnings=warnings, ) def _validate_load_case( self, load_case: dict[str, Any], mat_props: dict[str, float], dimensions: dict[str, float] | None, ) -> LoadCaseResult: """Validate a single load case.""" lc_id = load_case.get("id", str(uuid.uuid4())[:8]) # Extract load parameters force_n = load_case.get("force_n", 0) moment_nm = load_case.get("moment_nm", 0) pressure_mpa = load_case.get("pressure_mpa", 0) temperature_c = load_case.get("temperature_c", 25) # Get material limits yield_strength = mat_props.get("yield_strength_mpa", 100) ultimate_strength = mat_props.get("ultimate_strength_mpa", 150) max_temp = mat_props.get("max_service_temp_c", 100) # Calculate stress (simplified - assumes basic geometry) area_mm2 = 100.0 # Default cross-sectional area if dimensions: width = dimensions.get("width_mm", 10) height = dimensions.get("height_mm", 10) area_mm2 = width * height # Basic stress calculation axial_stress = force_n / area_mm2 if area_mm2 > 0 else 0 bending_stress = 0 if moment_nm and dimensions: # Simplified bending: M*c/I where c = height/2, I = width*height^3/12 height = dimensions.get("height_mm", 10) width = dimensions.get("width_mm", 10) c = height / 2 i = width * (height ** 3) / 12 bending_stress = (moment_nm * 1000 * c) / i if i > 0 else 0 # Combined stress (von Mises simplified for 1D) max_stress = abs(axial_stress) + abs(bending_stress) + pressure_mpa # Calculate safety factors yield_sf = yield_strength / max_stress if max_stress > 0 else float("inf") ultimate_sf = ultimate_strength / max_stress if max_stress > 0 else float("inf") # Check temperature limits temp_ok = temperature_c <= max_temp # Determine if load case passes passed = ( yield_sf >= self._required_sf and ultimate_sf >= self._required_sf and temp_ok ) failure_mode = None if yield_sf < self._required_sf: failure_mode = "yield_failure" elif ultimate_sf < self._required_sf: failure_mode = "ultimate_failure" elif not temp_ok: failure_mode = "thermal_failure" return LoadCaseResult( load_case_id=lc_id, max_stress_mpa=max_stress, safety_factor=min(yield_sf, ultimate_sf), passed=passed, failure_mode=failure_mode, details={ "axial_stress_mpa": axial_stress, "bending_stress_mpa": bending_stress, "yield_sf": yield_sf, "ultimate_sf": ultimate_sf, "temperature_ok": temp_ok, }, ) def _select_governing_equations(self, load_cases: list[dict[str, Any]]) -> str: """Select appropriate governing equations based on load types.""" equations = [] # Check load types has_static = any(lc.get("type") == "static" or lc.get("force_n") for lc in load_cases) has_thermal = any(lc.get("temperature_c") for lc in load_cases) has_dynamic = any(lc.get("type") == "dynamic" or lc.get("frequency_hz") for lc in load_cases) has_pressure = any(lc.get("pressure_mpa") for lc in load_cases) if has_static: equations.append("Linear elasticity (Hooke's Law)") if has_thermal: equations.append("Thermal expansion (α·ΔT)") if has_dynamic: equations.append("Modal analysis (eigenvalue)") if has_pressure: equations.append("Pressure vessel (hoop stress)") if not equations: equations.append("Linear elasticity (default)") return "; ".join(equations) def get_material_properties(self, material: str) -> dict[str, float] | None: """Get properties for a material.""" return self._materials.get(material.lower().replace(" ", "_")) def list_materials(self) -> list[str]: """List available materials.""" return list(self._materials.keys()) def add_material(self, name: str, properties: dict[str, float]) -> None: """Add a custom material to the database.""" self._materials[name.lower().replace(" ", "_")] = properties class StubPhysicsAuthority(PhysicsAuthorityInterface): """ Stub implementation for testing. Returns a minimal proof if design_ref present; else raises PhysicsUnderdefinedError. Note: This is a stub for testing. Use PhysicsAuthority for real validation. """ def validate_physics( self, design_ref: str, load_cases: list[dict[str, Any]] | None = None, **kwargs: Any, ) -> PhysicsProof | None: if not design_ref: raise PhysicsUnderdefinedError("design_ref required") return PhysicsProof( proof_id=f"stub_proof_{design_ref}", failure_modes_covered=["stub"], validation_status="stub_validated", warnings=["This is a stub validation - not for production use"], )