"""noctilith/sim/material_profiles.py — M13: VoxelDef → MaterialPipelineProfile.""" from __future__ import annotations import math from dataclasses import dataclass, field, replace from typing import Any, Dict, Optional, TYPE_CHECKING if TYPE_CHECKING: from engine.voxel_registry import VoxelDef from engine.sim.material_degradation import MaterialDegradationConfig from engine.sim.fracture import FractureBridgeConfig from engine.sim.world_reaction import WorldReactionConfig from engine.sim.debris_world_coupling import DebrisWorldCouplingConfig SCHEMA_VERSION = "noctilith.schema.v1" MODULE_NAME = "noctilith.sim.material_profiles" def clamp(v: float, lo: float, hi: float) -> float: return max(lo, min(hi, v)) @dataclass class MaterialPipelineProfile: voxel_name: str = "unknown" voxel_id: int = -1 degradation: MaterialDegradationConfig = field(default_factory=MaterialDegradationConfig) fracture: FractureBridgeConfig = field(default_factory=FractureBridgeConfig) world_reaction: WorldReactionConfig = field(default_factory=WorldReactionConfig) coupling: DebrisWorldCouplingConfig = field(default_factory=DebrisWorldCouplingConfig) archetype: str = "generic" thermal_fragility: float = 0.5 structural_index: float = 0.5 debris_tendency: float = 0.5 def summary(self) -> Dict[str, Any]: return { "schema_version": SCHEMA_VERSION, "module_name": MODULE_NAME, "voxel_name": self.voxel_name, "voxel_id": self.voxel_id, "archetype": self.archetype, "thermal_fragility": float(self.thermal_fragility), "structural_index": float(self.structural_index), "debris_tendency": float(self.debris_tendency), "fracture_threshold": float(self.fracture.fracture_risk_threshold), "propagation_gain": float(self.fracture.propagation_gain), "damage_gain": float(self.world_reaction.damage_gain), "entropy_gain": float(self.coupling.entropy_gain), "thermal_gain": float(self.coupling.thermal_gain), "fatigue_exponent": float(self.degradation.fatigue_exponent), } def derive_from_voxel(vdef: "VoxelDef") -> MaterialPipelineProfile: b = float(clamp(vdef.brittleness, 0.0, 1.0)) h = float(clamp(vdef.hardness, 0.0, 1.0)) por = float(clamp(vdef.porosity, 0.0, 1.0)) flm = float(clamp(vdef.flammability, 0.0, 1.0)) kth = float(max(1e-6, vdef.thermal_conductivity)) E = float(max(1.0, vdef.young_modulus)) Tm = float(max(1.0, vdef.melting_point_k)) k_norm = clamp(math.log10(kth + 1) / 2.5, 0.0, 1.0) E_norm = clamp(math.log10(E + 1) / 12.0, 0.0, 1.0) temp_w = clamp(0.25 + k_norm * 0.35, 0.25, 0.60) entropy_w = clamp(0.20 + por * 0.55, 0.20, 0.75) rms_w = clamp(1.0 - temp_w - entropy_w, 0.05, 0.40) fat_exp = clamp(1.05 + E_norm * 0.55 + por * 0.25, 1.05, 1.85) risk_gain = clamp(0.45 + b * 0.35 + por * 0.20, 0.45, 1.0) degradation = replace(MaterialDegradationConfig(), temp_weight=round(temp_w,4), entropy_weight=round(entropy_w,4), rms_weight=round(rms_w,4), fatigue_exponent=round(fat_exp,4), risk_gain=round(risk_gain,4)) frac_thresh = clamp(1.0 - b * 0.60, 0.35, 0.95) prop_gain = clamp(b * 0.55 + 0.10, 0.10, 0.65) prop_radius = 1 + (1 if b > 0.65 else 0) fracture = replace(FractureBridgeConfig(), fracture_risk_threshold=round(frac_thresh,4), propagation_gain=round(prop_gain,4), propagation_radius=prop_radius, return_fracture_risk_field=True) dmg_gain = clamp(1.0 - h * 0.65, 0.30, 0.90) debris_thr = clamp(0.60 + h * 0.30, 0.60, 0.95) dmg_thr = clamp(0.50 + h * 0.30, 0.50, 0.85) world_reaction = replace(WorldReactionConfig(), damage_gain=round(dmg_gain,4), damage_threshold=round(dmg_thr,4), debris_threshold=round(debris_thr,4)) ent_gain = clamp(0.20 + por * 0.65 + b * 0.15, 0.20, 1.0) therm_gain = clamp(0.08 + k_norm * 0.25, 0.08, 0.35) frag_thresh = clamp(1.0 - flm * 0.60, 0.35, 0.95) frag_gain = clamp(0.20 + flm * 0.45, 0.20, 0.65) coupling = replace(DebrisWorldCouplingConfig(), entropy_gain=round(ent_gain,4), thermal_gain=round(therm_gain,4), fragmentation_strength_threshold=round(frag_thresh,4), fragmentation_strength_gain=round(frag_gain,4)) thermal_fragility = clamp(1.0 - math.log10(Tm+1)/4.5, 0.0, 1.0) structural_index = clamp(E_norm*0.6 + h*0.4, 0.0, 1.0) debris_tendency = clamp(b*0.5 + flm*0.3 + (1.0-h)*0.2, 0.0, 1.0) archetype = _classify(b, h, por, flm) return MaterialPipelineProfile( voxel_name=str(vdef.name), voxel_id=int(vdef.id), degradation=degradation, fracture=fracture, world_reaction=world_reaction, coupling=coupling, archetype=archetype, thermal_fragility=round(thermal_fragility,4), structural_index=round(structural_index,4), debris_tendency=round(debris_tendency,4)) def _classify(b: float, h: float, por: float, flm: float) -> str: if flm > 0.5: return "organic" if por > 0.3: return "porous" if b > 0.7 and h > 0.5: return "brittle" if b < 0.3 and h > 0.5: return "ductile" if b > 0.6 and h < 0.4: return "fused" return "generic" class MaterialProfileCache: def __init__(self) -> None: self._cache: Dict[int, MaterialPipelineProfile] = {} def get(self, voxel_id: int, registry=None) -> Optional[MaterialPipelineProfile]: if voxel_id in self._cache: return self._cache[voxel_id] if registry is None: from engine.voxel_registry import REGISTRY registry = REGISTRY vdef = registry.by_id(voxel_id) if vdef is None: return None profile = derive_from_voxel(vdef) self._cache[voxel_id] = profile return profile def get_by_name(self, name: str, registry=None) -> Optional[MaterialPipelineProfile]: if registry is None: from engine.voxel_registry import REGISTRY registry = REGISTRY vdef = registry.by_name(name) if vdef is None: return None return self.get(vdef.id, registry) def warm_all(self, registry=None) -> int: if registry is None: from engine.voxel_registry import REGISTRY registry = REGISTRY for vid in registry.all_ids(): self.get(vid, registry) return len(self._cache) def clear(self) -> None: self._cache.clear() def __len__(self) -> int: return len(self._cache) PROFILE_CACHE = MaterialProfileCache()