|
|
@@ -0,0 +1,392 @@
|
|
|
+"""Adaptive Simulation Loop (P3-M5).
|
|
|
+
|
|
|
+Integrates all P3 components into a complete end-to-end adaptive
|
|
|
+simulation closed loop:
|
|
|
+1. AI plan generation (natural language -> structured plan)
|
|
|
+2. L0 pre-screening (exclude infeasible regions)
|
|
|
+3. Feasibility-first search (active learning + trust region)
|
|
|
+4. Simulation execution (external Motor-CAD/Maxwell)
|
|
|
+5. AI result analysis (trends, anomalies, optimization suggestions)
|
|
|
+6. Multi-fidelity calibration + confidence grading
|
|
|
+7. Experience library enhancement (extract design knowledge)
|
|
|
+8. Adaptive next-batch selection (loop back to step 3)
|
|
|
+"""
|
|
|
+import json
|
|
|
+from typing import Dict, List, Optional, Any, Callable
|
|
|
+from datetime import datetime
|
|
|
+from enum import Enum
|
|
|
+
|
|
|
+from ..services.l0_prescreening import L0PreScreeningEngine
|
|
|
+from ..services.feasibility_search import FeasibilityFirstSearch, ParameterRange
|
|
|
+from ..services.plan_generator import AIPlanGenerator
|
|
|
+from ..services.result_analyst import AIResultAnalyst
|
|
|
+from ..services.experience_enhancer import ExperienceEnhancer
|
|
|
+
|
|
|
+
|
|
|
+class LoopPhase(str, Enum):
|
|
|
+ """Phases of the adaptive simulation loop."""
|
|
|
+ INIT = "init"
|
|
|
+ PLAN_GENERATED = "plan_generated"
|
|
|
+ L0_SCREENED = "l0_screened"
|
|
|
+ SEARCH_INITIALIZED = "search_initialized"
|
|
|
+ BATCH_SELECTED = "batch_selected"
|
|
|
+ SIMULATION_RUNNING = "simulation_running"
|
|
|
+ RESULTS_ANALYZED = "results_analyzed"
|
|
|
+ EXPERIENCE_UPDATED = "experience_updated"
|
|
|
+ CONVERGED = "converged"
|
|
|
+ BUDGET_EXHAUSTED = "budget_exhausted"
|
|
|
+ COMPLETED = "completed"
|
|
|
+
|
|
|
+
|
|
|
+class AdaptiveLoop:
|
|
|
+ """Complete adaptive simulation closed loop.
|
|
|
+
|
|
|
+ This orchestrator ties together all P3-M1 through P3-M5 components
|
|
|
+ into a single, stateful simulation optimization loop.
|
|
|
+ """
|
|
|
+
|
|
|
+ def __init__(
|
|
|
+ self,
|
|
|
+ loop_id: Optional[str] = None,
|
|
|
+ user_requirement: Optional[str] = None,
|
|
|
+ total_budget: int = 80,
|
|
|
+ batch_size: int = 4,
|
|
|
+ ):
|
|
|
+ self.loop_id = loop_id or f"loop_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
|
|
+ self.user_requirement = user_requirement
|
|
|
+ self.total_budget = total_budget
|
|
|
+ self.batch_size = batch_size
|
|
|
+ self.phase = LoopPhase.INIT
|
|
|
+
|
|
|
+ # Components
|
|
|
+ self.l0_engine = L0PreScreeningEngine()
|
|
|
+ self.plan_generator = AIPlanGenerator(l0_engine=self.l0_engine)
|
|
|
+ self.result_analyst = AIResultAnalyst()
|
|
|
+ self.experience_enhancer = ExperienceEnhancer()
|
|
|
+ self.search: Optional[FeasibilityFirstSearch] = None
|
|
|
+
|
|
|
+ # State
|
|
|
+ self.plan: Optional[Dict[str, Any]] = None
|
|
|
+ self.plan_validation: Optional[Dict[str, Any]] = None
|
|
|
+ self.all_results: List[Dict[str, Any]] = []
|
|
|
+ self.latest_analysis: Optional[Dict[str, Any]] = None
|
|
|
+ self.latest_insights: Optional[Dict[str, Any]] = None
|
|
|
+ self.history: List[Dict[str, Any]] = []
|
|
|
+ self.created_at = datetime.now().isoformat()
|
|
|
+ self.updated_at = datetime.now().isoformat()
|
|
|
+
|
|
|
+ def generate_plan(self, user_requirement: Optional[str] = None) -> Dict[str, Any]:
|
|
|
+ """Step 1: Generate simulation plan from natural language.
|
|
|
+
|
|
|
+ Args:
|
|
|
+ user_requirement: Natural language requirement (uses stored if None)
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ Generated plan with validation
|
|
|
+ """
|
|
|
+ if user_requirement:
|
|
|
+ self.user_requirement = user_requirement
|
|
|
+
|
|
|
+ if not self.user_requirement:
|
|
|
+ raise ValueError("No user requirement provided")
|
|
|
+
|
|
|
+ result = self.plan_generator.generate(self.user_requirement)
|
|
|
+ self.plan = result.get("plan", {})
|
|
|
+ self.plan_validation = result.get("validation", {})
|
|
|
+ self.phase = LoopPhase.PLAN_GENERATED
|
|
|
+ self._record_history("plan_generated", {"plan_name": self.plan.get("plan_name", "")})
|
|
|
+
|
|
|
+ return {
|
|
|
+ "loop_id": self.loop_id,
|
|
|
+ "phase": self.phase.value,
|
|
|
+ "plan": self.plan,
|
|
|
+ "validation": self.plan_validation,
|
|
|
+ }
|
|
|
+
|
|
|
+ def initialize_search(self) -> Dict[str, Any]:
|
|
|
+ """Step 2-3: Initialize feasibility-first search from plan.
|
|
|
+
|
|
|
+ Converts plan scan variables to search parameters and
|
|
|
+ generates initial LHS batch with L0 pre-screening.
|
|
|
+ """
|
|
|
+ if not self.plan:
|
|
|
+ raise RuntimeError("Plan not generated. Call generate_plan() first.")
|
|
|
+
|
|
|
+ # Convert scan variables to ParameterRange
|
|
|
+ parameters = []
|
|
|
+ for var in self.plan.get("scan_variables", []):
|
|
|
+ if isinstance(var, dict) and "name" in var and "min_value" in var and "max_value" in var:
|
|
|
+ parameters.append(ParameterRange(
|
|
|
+ name=var["name"],
|
|
|
+ min_value=float(var["min_value"]),
|
|
|
+ max_value=float(var["max_value"]),
|
|
|
+ step=var.get("step"),
|
|
|
+ unit=var.get("unit", ""),
|
|
|
+ description=var.get("description", ""),
|
|
|
+ ))
|
|
|
+
|
|
|
+ if not parameters:
|
|
|
+ raise RuntimeError("No valid scan variables in plan")
|
|
|
+
|
|
|
+ # Get search strategy from plan
|
|
|
+ search_strategy = self.plan.get("search_strategy", {})
|
|
|
+ acceptance = self.plan.get("acceptance_criteria", {})
|
|
|
+
|
|
|
+ self.search = FeasibilityFirstSearch(
|
|
|
+ parameters=parameters,
|
|
|
+ l0_engine=self.l0_engine,
|
|
|
+ total_budget=search_strategy.get("max_solver_calls", self.total_budget),
|
|
|
+ batch_size=search_strategy.get("batch_size", self.batch_size),
|
|
|
+ initial_samples=search_strategy.get("initial_samples", 16),
|
|
|
+ objective_metric=acceptance.get("objective_metric", "tavg_nm"),
|
|
|
+ objective_direction=acceptance.get("objective_direction", "maximize"),
|
|
|
+ )
|
|
|
+
|
|
|
+ # Generate initial batch
|
|
|
+ initial_batch = self.search.generate_initial_batch()
|
|
|
+ self.phase = LoopPhase.SEARCH_INITIALIZED
|
|
|
+ self._record_history("search_initialized", {
|
|
|
+ "n_parameters": len(parameters),
|
|
|
+ "initial_batch_size": len(initial_batch),
|
|
|
+ })
|
|
|
+
|
|
|
+ return {
|
|
|
+ "loop_id": self.loop_id,
|
|
|
+ "phase": self.phase.value,
|
|
|
+ "search_id": self.search.state.run_id,
|
|
|
+ "initial_batch": [
|
|
|
+ {"id": p.id, "params": p.params, "status": p.status}
|
|
|
+ for p in initial_batch
|
|
|
+ ],
|
|
|
+ "state": self.search.get_state_summary(),
|
|
|
+ }
|
|
|
+
|
|
|
+ def get_next_batch(self) -> Dict[str, Any]:
|
|
|
+ """Step 3 (loop): Select next batch of points to simulate.
|
|
|
+
|
|
|
+ Uses active learning with trust region refinement.
|
|
|
+ """
|
|
|
+ if not self.search:
|
|
|
+ raise RuntimeError("Search not initialized. Call initialize_search() first.")
|
|
|
+
|
|
|
+ batch = self.search.select_next_batch()
|
|
|
+ if not batch:
|
|
|
+ if self.search.state.convergence_status == "converged":
|
|
|
+ self.phase = LoopPhase.CONVERGED
|
|
|
+ else:
|
|
|
+ self.phase = LoopPhase.BUDGET_EXHAUSTED
|
|
|
+ return {
|
|
|
+ "loop_id": self.loop_id,
|
|
|
+ "phase": self.phase.value,
|
|
|
+ "batch": [],
|
|
|
+ "message": f"Search {self.search.state.convergence_status}",
|
|
|
+ }
|
|
|
+
|
|
|
+ self.phase = LoopPhase.BATCH_SELECTED
|
|
|
+ self._record_history("batch_selected", {
|
|
|
+ "batch_id": self.search.state.current_batch - 1,
|
|
|
+ "batch_size": len(batch),
|
|
|
+ })
|
|
|
+
|
|
|
+ return {
|
|
|
+ "loop_id": self.loop_id,
|
|
|
+ "phase": self.phase.value,
|
|
|
+ "batch_id": self.search.state.current_batch - 1,
|
|
|
+ "points": [
|
|
|
+ {"id": p.id, "params": p.params, "status": p.status}
|
|
|
+ for p in batch
|
|
|
+ ],
|
|
|
+ "state": self.search.get_state_summary(),
|
|
|
+ }
|
|
|
+
|
|
|
+ def report_results(self, point_results: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
|
+ """Step 4-5: Report simulation results and trigger analysis.
|
|
|
+
|
|
|
+ Args:
|
|
|
+ point_results: List of {point_id, metrics, status} dicts
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ Analysis results and next-step recommendations
|
|
|
+ """
|
|
|
+ if not self.search:
|
|
|
+ raise RuntimeError("Search not initialized")
|
|
|
+
|
|
|
+ # Report each result to search
|
|
|
+ for pr in point_results:
|
|
|
+ point_id = pr.get("point_id")
|
|
|
+ metrics = pr.get("metrics", {})
|
|
|
+ status = pr.get("status", "ok")
|
|
|
+ if point_id is not None:
|
|
|
+ self.search.report_result(point_id, metrics, status)
|
|
|
+ # Add to all results
|
|
|
+ result_entry = {"point_id": point_id, **metrics}
|
|
|
+ self.all_results.append(result_entry)
|
|
|
+
|
|
|
+ # Run AI analysis on accumulated results
|
|
|
+ targets = self.plan.get("acceptance_criteria", {}).get("hard_constraints", {})
|
|
|
+ target_dict = {}
|
|
|
+ if isinstance(targets, list):
|
|
|
+ for constraint in targets:
|
|
|
+ # Parse simple constraints like "efficiency_pct >= 92"
|
|
|
+ if ">=" in constraint:
|
|
|
+ parts = constraint.split(">=")
|
|
|
+ target_dict[parts[0].strip()] = float(parts[1].strip())
|
|
|
+ elif "<=" in constraint:
|
|
|
+ parts = constraint.split("<=")
|
|
|
+ target_dict[parts[0].strip()] = float(parts[1].strip())
|
|
|
+
|
|
|
+ self.latest_analysis = self.result_analyst.analyze(
|
|
|
+ results=self.all_results,
|
|
|
+ targets=target_dict if target_dict else None,
|
|
|
+ fidelity="L3",
|
|
|
+ scan_parameters=[p.name for p in self.search.parameters] if self.search else None,
|
|
|
+ )
|
|
|
+
|
|
|
+ self.phase = LoopPhase.RESULTS_ANALYZED
|
|
|
+ self._record_history("results_analyzed", {
|
|
|
+ "n_results": len(self.all_results),
|
|
|
+ "confidence": self.latest_analysis.get("confidence", {}).get("grade", "?"),
|
|
|
+ })
|
|
|
+
|
|
|
+ return {
|
|
|
+ "loop_id": self.loop_id,
|
|
|
+ "phase": self.phase.value,
|
|
|
+ "analysis": self.latest_analysis,
|
|
|
+ "search_state": self.search.get_state_summary(),
|
|
|
+ "recommendation": self._get_recommendation(),
|
|
|
+ }
|
|
|
+
|
|
|
+ def update_experience(self) -> Dict[str, Any]:
|
|
|
+ """Step 6: Extract insights and update experience library.
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ Extracted insights and experience entry
|
|
|
+ """
|
|
|
+ if len(self.all_results) < 5:
|
|
|
+ return {"message": "Insufficient results for experience extraction (need >= 5)"}
|
|
|
+
|
|
|
+ self.latest_insights = self.experience_enhancer.extract_insights(
|
|
|
+ results=self.all_results,
|
|
|
+ project_context={
|
|
|
+ "topology": self.plan.get("topology", ""),
|
|
|
+ "plan_name": self.plan.get("plan_name", ""),
|
|
|
+ },
|
|
|
+ )
|
|
|
+
|
|
|
+ # Generate experience entry
|
|
|
+ experience_entry = self.experience_enhancer.generate_experience_entry(
|
|
|
+ insights=self.latest_insights,
|
|
|
+ project_name=self.plan.get("plan_name", "unknown"),
|
|
|
+ topology=self.plan.get("topology", "unknown"),
|
|
|
+ )
|
|
|
+
|
|
|
+ self.phase = LoopPhase.EXPERIENCE_UPDATED
|
|
|
+ self._record_history("experience_updated", {
|
|
|
+ "n_rules": len(experience_entry.get("design_rules", [])),
|
|
|
+ })
|
|
|
+
|
|
|
+ return {
|
|
|
+ "loop_id": self.loop_id,
|
|
|
+ "phase": self.phase.value,
|
|
|
+ "insights": self.latest_insights,
|
|
|
+ "experience_entry": experience_entry,
|
|
|
+ }
|
|
|
+
|
|
|
+ def check_completion(self) -> Dict[str, Any]:
|
|
|
+ """Check if loop should terminate.
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ Completion status and reason
|
|
|
+ """
|
|
|
+ if not self.search:
|
|
|
+ return {"completed": False, "reason": "search_not_initialized"}
|
|
|
+
|
|
|
+ state = self.search.get_state_summary()
|
|
|
+
|
|
|
+ if state["convergence_status"] == "converged":
|
|
|
+ self.phase = LoopPhase.CONVERGED
|
|
|
+ return {"completed": True, "reason": "converged", "state": state}
|
|
|
+
|
|
|
+ if state["remaining_budget"] <= 0:
|
|
|
+ self.phase = LoopPhase.BUDGET_EXHAUSTED
|
|
|
+ return {"completed": True, "reason": "budget_exhausted", "state": state}
|
|
|
+
|
|
|
+ return {"completed": False, "reason": "continue", "state": state}
|
|
|
+
|
|
|
+ def _get_recommendation(self) -> str:
|
|
|
+ """Generate next-step recommendation based on current state."""
|
|
|
+ if not self.search:
|
|
|
+ return "Initialize search first"
|
|
|
+
|
|
|
+ state = self.search.get_state_summary()
|
|
|
+ analysis = self.latest_analysis or {}
|
|
|
+ confidence = analysis.get("confidence", {}).get("grade", "?")
|
|
|
+
|
|
|
+ if state["convergence_status"] == "converged":
|
|
|
+ return f"Search converged. Best: {state['best_objective_value']}. Confidence: {confidence}."
|
|
|
+
|
|
|
+ if state["remaining_budget"] <= 0:
|
|
|
+ return "Budget exhausted."
|
|
|
+
|
|
|
+ if confidence in ("D",):
|
|
|
+ return f"Low confidence ({confidence}). Consider increasing sample count or fidelity level."
|
|
|
+
|
|
|
+ if state["trust_region_active"]:
|
|
|
+ return f"Trust region active (radius={state['trust_region_radius']:.3f}). Continue exploitation. Best: {state['best_objective_value']}"
|
|
|
+
|
|
|
+ return f"Exploration phase. {state['feasible_points']} feasible points found. Continue sampling."
|
|
|
+
|
|
|
+ def _record_history(self, event: str, data: Dict[str, Any]):
|
|
|
+ """Record a history event."""
|
|
|
+ self.history.append({
|
|
|
+ "event": event,
|
|
|
+ "timestamp": datetime.now().isoformat(),
|
|
|
+ "phase": self.phase.value,
|
|
|
+ "data": data,
|
|
|
+ })
|
|
|
+ self.updated_at = datetime.now().isoformat()
|
|
|
+
|
|
|
+ def get_state(self) -> Dict[str, Any]:
|
|
|
+ """Get complete loop state."""
|
|
|
+ return {
|
|
|
+ "loop_id": self.loop_id,
|
|
|
+ "phase": self.phase.value,
|
|
|
+ "user_requirement": self.user_requirement,
|
|
|
+ "total_budget": self.total_budget,
|
|
|
+ "batch_size": self.batch_size,
|
|
|
+ "plan_name": self.plan.get("plan_name", "") if self.plan else None,
|
|
|
+ "n_results": len(self.all_results),
|
|
|
+ "search_state": self.search.get_state_summary() if self.search else None,
|
|
|
+ "latest_confidence": self.latest_analysis.get("confidence", {}).get("grade") if self.latest_analysis else None,
|
|
|
+ "history": self.history,
|
|
|
+ "created_at": self.created_at,
|
|
|
+ "updated_at": self.updated_at,
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+# Global loop registry (in-memory, P3-M5 prototype)
|
|
|
+_loops: Dict[str, AdaptiveLoop] = {}
|
|
|
+
|
|
|
+
|
|
|
+def get_loop(loop_id: str) -> Optional[AdaptiveLoop]:
|
|
|
+ """Get an existing adaptive loop by ID."""
|
|
|
+ return _loops.get(loop_id)
|
|
|
+
|
|
|
+
|
|
|
+def create_loop(user_requirement: str, total_budget: int = 80, batch_size: int = 4) -> AdaptiveLoop:
|
|
|
+ """Create a new adaptive loop."""
|
|
|
+ loop = AdaptiveLoop(
|
|
|
+ user_requirement=user_requirement,
|
|
|
+ total_budget=total_budget,
|
|
|
+ batch_size=batch_size,
|
|
|
+ )
|
|
|
+ _loops[loop.loop_id] = loop
|
|
|
+ return loop
|
|
|
+
|
|
|
+
|
|
|
+def list_loops() -> List[Dict[str, Any]]:
|
|
|
+ """List all active loops."""
|
|
|
+ return [
|
|
|
+ {"loop_id": lid, "phase": loop.phase.value, "n_results": len(loop.all_results)}
|
|
|
+ for lid, loop in _loops.items()
|
|
|
+ ]
|