|
|
@@ -0,0 +1,503 @@
|
|
|
+"""Feasibility-First Search Framework (P3-M2).
|
|
|
+
|
|
|
+Pure Python implementation (no numpy/scipy/sklearn dependency).
|
|
|
+Implements:
|
|
|
+- LHS (Latin Hypercube Sampling) for initial space coverage
|
|
|
+- Active learning batch selection (distance + uncertainty heuristic)
|
|
|
+- Constrained feasibility prioritization
|
|
|
+- Local trust region refinement
|
|
|
+- Search state management with checkpoint support
|
|
|
+
|
|
|
+Per third-party review: default mode is feasibility-first
|
|
|
+(constrained Bayesian / active learning), not fixed full-factorial DoE.
|
|
|
+"""
|
|
|
+import math
|
|
|
+import random
|
|
|
+import hashlib
|
|
|
+import json
|
|
|
+from dataclasses import dataclass, field
|
|
|
+from typing import Dict, List, Optional, Tuple, Any, Callable
|
|
|
+from datetime import datetime
|
|
|
+
|
|
|
+from .l0_prescreening import L0PreScreeningEngine, FeasibilityReport
|
|
|
+
|
|
|
+
|
|
|
+@dataclass
|
|
|
+class ParameterRange:
|
|
|
+ """Definition of a scan parameter range."""
|
|
|
+ name: str
|
|
|
+ min_value: float
|
|
|
+ max_value: float
|
|
|
+ step: Optional[float] = None # if set, values are quantized
|
|
|
+ unit: str = ""
|
|
|
+ description: str = ""
|
|
|
+
|
|
|
+ def normalize(self, value: float) -> float:
|
|
|
+ """Normalize value to [0, 1] range."""
|
|
|
+ if self.max_value == self.min_value:
|
|
|
+ return 0.5
|
|
|
+ return (value - self.min_value) / (self.max_value - self.min_value)
|
|
|
+
|
|
|
+ def denormalize(self, norm: float) -> float:
|
|
|
+ """Denormalize from [0, 1] to actual range."""
|
|
|
+ value = self.min_value + norm * (self.max_value - self.min_value)
|
|
|
+ if self.step is not None and self.step > 0:
|
|
|
+ value = round(value / self.step) * self.step
|
|
|
+ value = max(self.min_value, min(self.max_value, value))
|
|
|
+ return value
|
|
|
+
|
|
|
+ def random_value(self, rng: random.Random) -> float:
|
|
|
+ """Generate a random value within range."""
|
|
|
+ return self.denormalize(rng.random())
|
|
|
+
|
|
|
+
|
|
|
+@dataclass
|
|
|
+class SearchPoint:
|
|
|
+ """A single point in the search space."""
|
|
|
+ id: int
|
|
|
+ params: Dict[str, float]
|
|
|
+ status: str = "pending" # pending / running / ok / failed / infeasible
|
|
|
+ metrics: Dict[str, float] = field(default_factory=dict)
|
|
|
+ feasibility_report: Optional[Dict[str, Any]] = None
|
|
|
+ surrogate_prediction: Optional[float] = None
|
|
|
+ surrogate_uncertainty: Optional[float] = None
|
|
|
+ batch_id: int = 0
|
|
|
+ created_at: str = ""
|
|
|
+
|
|
|
+ def param_hash(self) -> str:
|
|
|
+ """Generate a hash of parameter values for caching/dedup."""
|
|
|
+ sorted_params = sorted(self.params.items())
|
|
|
+ param_str = json.dumps(sorted_params, sort_keys=True)
|
|
|
+ return hashlib.md5(param_str.encode()).hexdigest()[:12]
|
|
|
+
|
|
|
+
|
|
|
+@dataclass
|
|
|
+class SearchState:
|
|
|
+ """Complete state of an adaptive search run."""
|
|
|
+ run_id: str
|
|
|
+ parameters: List[ParameterRange]
|
|
|
+ points: List[SearchPoint] = field(default_factory=list)
|
|
|
+ current_batch: int = 0
|
|
|
+ total_budget: int = 80
|
|
|
+ used_budget: int = 0
|
|
|
+ batch_size: int = 4
|
|
|
+ search_method: str = "active_learning"
|
|
|
+ trust_region_active: bool = False
|
|
|
+ trust_region_center: Optional[Dict[str, float]] = None
|
|
|
+ trust_region_radius: float = 0.3 # fraction of normalized range
|
|
|
+ best_feasible_point: Optional[SearchPoint] = None
|
|
|
+ best_objective_value: float = float('inf')
|
|
|
+ objective_metric: str = "tavg_nm" # metric to optimize
|
|
|
+ objective_direction: str = "maximize" # maximize / minimize
|
|
|
+ convergence_status: str = "searching" # searching / converged / stalled / budget_exhausted
|
|
|
+ history: List[Dict[str, Any]] = field(default_factory=list)
|
|
|
+ created_at: str = ""
|
|
|
+ updated_at: str = ""
|
|
|
+
|
|
|
+ def get_param_names(self) -> List[str]:
|
|
|
+ return [p.name for p in self.parameters]
|
|
|
+
|
|
|
+ def get_param_by_name(self, name: str) -> Optional[ParameterRange]:
|
|
|
+ for p in self.parameters:
|
|
|
+ if p.name == name:
|
|
|
+ return p
|
|
|
+ return None
|
|
|
+
|
|
|
+ def get_pending_points(self) -> List[SearchPoint]:
|
|
|
+ return [p for p in self.points if p.status == "pending"]
|
|
|
+
|
|
|
+ def get_completed_points(self) -> List[SearchPoint]:
|
|
|
+ return [p for p in self.points if p.status in ("ok", "failed", "infeasible")]
|
|
|
+
|
|
|
+ def get_feasible_points(self) -> List[SearchPoint]:
|
|
|
+ return [p for p in self.points if p.status == "ok" and p.feasibility_report and p.feasibility_report.get("feasible", False)]
|
|
|
+
|
|
|
+ def remaining_budget(self) -> int:
|
|
|
+ return self.total_budget - self.used_budget
|
|
|
+
|
|
|
+
|
|
|
+class FeasibilityFirstSearch:
|
|
|
+ """Feasibility-first adaptive search engine.
|
|
|
+
|
|
|
+ Implements the recommended default path from third-party review:
|
|
|
+ L0 pre-screening + initial samples + active learning batch selection
|
|
|
+ + local trust region refinement.
|
|
|
+ """
|
|
|
+
|
|
|
+ def __init__(
|
|
|
+ self,
|
|
|
+ parameters: List[ParameterRange],
|
|
|
+ l0_engine: Optional[L0PreScreeningEngine] = None,
|
|
|
+ total_budget: int = 80,
|
|
|
+ batch_size: int = 4,
|
|
|
+ initial_samples: int = 16,
|
|
|
+ objective_metric: str = "tavg_nm",
|
|
|
+ objective_direction: str = "maximize",
|
|
|
+ seed: int = 42,
|
|
|
+ ):
|
|
|
+ self.rng = random.Random(seed)
|
|
|
+ self.parameters = parameters
|
|
|
+ self.l0_engine = l0_engine or L0PreScreeningEngine()
|
|
|
+ self.total_budget = total_budget
|
|
|
+ self.batch_size = batch_size
|
|
|
+ self.initial_samples = initial_samples
|
|
|
+ self.objective_metric = objective_metric
|
|
|
+ self.objective_direction = objective_direction
|
|
|
+ self.state = SearchState(
|
|
|
+ run_id=f"search_{datetime.now().strftime('%Y%m%d_%H%M%S')}",
|
|
|
+ parameters=parameters,
|
|
|
+ total_budget=total_budget,
|
|
|
+ batch_size=batch_size,
|
|
|
+ objective_metric=objective_metric,
|
|
|
+ objective_direction=objective_direction,
|
|
|
+ created_at=datetime.now().isoformat(),
|
|
|
+ )
|
|
|
+
|
|
|
+ def latin_hypercube_sample(self, n_samples: int) -> List[Dict[str, float]]:
|
|
|
+ """Generate Latin Hypercube Samples in normalized space.
|
|
|
+
|
|
|
+ Pure Python implementation (no numpy dependency).
|
|
|
+ """
|
|
|
+ n_params = len(self.parameters)
|
|
|
+ # Create n_samples intervals per dimension
|
|
|
+ intervals = []
|
|
|
+ for _ in range(n_params):
|
|
|
+ # Shuffle interval assignments
|
|
|
+ assignments = list(range(n_samples))
|
|
|
+ self.rng.shuffle(assignments)
|
|
|
+ intervals.append(assignments)
|
|
|
+
|
|
|
+ samples = []
|
|
|
+ for i in range(n_samples):
|
|
|
+ sample = {}
|
|
|
+ for j, param in enumerate(self.parameters):
|
|
|
+ # Random point within assigned interval
|
|
|
+ interval_idx = intervals[j][i]
|
|
|
+ norm = (interval_idx + self.rng.random()) / n_samples
|
|
|
+ sample[param.name] = param.denormalize(norm)
|
|
|
+ samples.append(sample)
|
|
|
+ return samples
|
|
|
+
|
|
|
+ def _min_distance_to_existing(self, params: Dict[str, float], existing: List[Dict[str, float]]) -> float:
|
|
|
+ """Calculate minimum normalized Euclidean distance to existing points."""
|
|
|
+ if not existing:
|
|
|
+ return float('inf')
|
|
|
+ min_dist = float('inf')
|
|
|
+ for ex in existing:
|
|
|
+ dist_sq = 0.0
|
|
|
+ for param in self.parameters:
|
|
|
+ v1 = param.normalize(params.get(param.name, param.min_value))
|
|
|
+ v2 = param.normalize(ex.get(param.name, param.min_value))
|
|
|
+ dist_sq += (v1 - v2) ** 2
|
|
|
+ dist = math.sqrt(dist_sq)
|
|
|
+ min_dist = min(min_dist, dist)
|
|
|
+ return min_dist
|
|
|
+
|
|
|
+ def _is_duplicate(self, params: Dict[str, float], existing_points: List[SearchPoint]) -> bool:
|
|
|
+ """Check if parameter set is a duplicate of an existing point."""
|
|
|
+ param_hash = hashlib.md5(
|
|
|
+ json.dumps(sorted(params.items()), sort_keys=True).encode()
|
|
|
+ ).hexdigest()[:12]
|
|
|
+ return any(p.param_hash() == param_hash for p in existing_points)
|
|
|
+
|
|
|
+ def generate_initial_batch(self) -> List[SearchPoint]:
|
|
|
+ """Generate initial LHS samples, filtered by L0 feasibility.
|
|
|
+
|
|
|
+ Prioritizes feasible points but includes some infeasible for
|
|
|
+ boundary learning (active learning needs both classes).
|
|
|
+ """
|
|
|
+ # Generate more samples than needed, then select diverse subset
|
|
|
+ n_candidates = max(self.initial_samples * 3, 20)
|
|
|
+ candidates = self.latin_hypercube_sample(n_candidates)
|
|
|
+
|
|
|
+ # L0 pre-screen all candidates
|
|
|
+ scored = []
|
|
|
+ for params in candidates:
|
|
|
+ report = self.l0_engine.evaluate(params)
|
|
|
+ scored.append((params, report))
|
|
|
+
|
|
|
+ # Separate feasible and infeasible
|
|
|
+ feasible = [(p, r) for p, r in scored if r.feasible]
|
|
|
+ infeasible = [(p, r) for p, r in scored if not r.feasible]
|
|
|
+
|
|
|
+ # Select diverse subset: mostly feasible, some infeasible for boundary
|
|
|
+ n_feasible = min(len(feasible), int(self.initial_samples * 0.75))
|
|
|
+ n_infeasible = min(len(infeasible), self.initial_samples - n_feasible)
|
|
|
+
|
|
|
+ selected = []
|
|
|
+ selected_params = []
|
|
|
+
|
|
|
+ # Greedy max-distance selection for feasible points
|
|
|
+ feasible.sort(key=lambda x: x[1].pass_rate, reverse=True)
|
|
|
+ for params, report in feasible:
|
|
|
+ if len(selected) >= n_feasible:
|
|
|
+ break
|
|
|
+ # Check duplicate against already selected params
|
|
|
+ is_dup = any(
|
|
|
+ hashlib.md5(json.dumps(sorted(p.items()), sort_keys=True).encode()).hexdigest()[:12] ==
|
|
|
+ hashlib.md5(json.dumps(sorted(params.items()), sort_keys=True).encode()).hexdigest()[:12]
|
|
|
+ for p, _ in selected
|
|
|
+ )
|
|
|
+ if not is_dup:
|
|
|
+ dist = self._min_distance_to_existing(params, selected_params)
|
|
|
+ if dist > 0.1 or len(selected) < 3:
|
|
|
+ selected.append((params, report))
|
|
|
+ selected_params.append(params)
|
|
|
+
|
|
|
+ # Add infeasible points for boundary learning
|
|
|
+ for params, report in infeasible:
|
|
|
+ if len(selected) >= self.initial_samples:
|
|
|
+ break
|
|
|
+ is_dup = any(
|
|
|
+ hashlib.md5(json.dumps(sorted(p.items()), sort_keys=True).encode()).hexdigest()[:12] ==
|
|
|
+ hashlib.md5(json.dumps(sorted(params.items()), sort_keys=True).encode()).hexdigest()[:12]
|
|
|
+ for p, _ in selected
|
|
|
+ )
|
|
|
+ if not is_dup:
|
|
|
+ selected.append((params, report))
|
|
|
+ selected_params.append(params)
|
|
|
+
|
|
|
+ # Create SearchPoint objects
|
|
|
+ points = []
|
|
|
+ for i, (params, report) in enumerate(selected):
|
|
|
+ point = SearchPoint(
|
|
|
+ id=len(self.state.points) + i,
|
|
|
+ params=params,
|
|
|
+ status="infeasible" if not report.feasible else "pending",
|
|
|
+ feasibility_report=report.to_dict(),
|
|
|
+ batch_id=0,
|
|
|
+ created_at=datetime.now().isoformat(),
|
|
|
+ )
|
|
|
+ if not report.feasible:
|
|
|
+ # Mark as completed (L0 rejected) without using solver budget
|
|
|
+ point.status = "infeasible"
|
|
|
+ points.append(point)
|
|
|
+
|
|
|
+ self.state.points.extend(points)
|
|
|
+ self.state.current_batch = 1
|
|
|
+ self.state.used_budget += sum(1 for p in points if p.status == "pending")
|
|
|
+ self.state.updated_at = datetime.now().isoformat()
|
|
|
+
|
|
|
+ return [p for p in points if p.status == "pending"]
|
|
|
+
|
|
|
+ def select_next_batch(self) -> List[SearchPoint]:
|
|
|
+ """Select next batch of points using active learning.
|
|
|
+
|
|
|
+ Strategy:
|
|
|
+ 1. If feasible points found, activate trust region around best
|
|
|
+ 2. Select points balancing:
|
|
|
+ - Exploration: high distance from existing points
|
|
|
+ - Exploitation: near best feasible point (trust region)
|
|
|
+ - Uncertainty: points near feasibility boundary
|
|
|
+ 3. L0 pre-screen all candidates, reject obviously infeasible
|
|
|
+ """
|
|
|
+ if self.state.remaining_budget() <= 0:
|
|
|
+ self.state.convergence_status = "budget_exhausted"
|
|
|
+ return []
|
|
|
+
|
|
|
+ feasible_points = self.state.get_feasible_points()
|
|
|
+ completed_params = [p.params for p in self.state.get_completed_points()]
|
|
|
+
|
|
|
+ # Activate trust region if we have enough feasible points
|
|
|
+ if len(feasible_points) >= 3 and not self.state.trust_region_active:
|
|
|
+ best = max(feasible_points, key=lambda p: p.metrics.get(self.objective_metric, 0))
|
|
|
+ self.state.trust_region_active = True
|
|
|
+ self.state.trust_region_center = best.params
|
|
|
+ self.state.best_feasible_point = best
|
|
|
+ self.state.history.append({
|
|
|
+ "event": "trust_region_activated",
|
|
|
+ "center": best.params,
|
|
|
+ "batch": self.state.current_batch,
|
|
|
+ })
|
|
|
+
|
|
|
+ # Generate candidates
|
|
|
+ n_candidates = 50
|
|
|
+ candidates = []
|
|
|
+
|
|
|
+ if self.state.trust_region_active and self.state.trust_region_center:
|
|
|
+ # 70% from trust region, 30% from global exploration
|
|
|
+ n_trust = int(n_candidates * 0.7)
|
|
|
+ n_global = n_candidates - n_trust
|
|
|
+
|
|
|
+ # Trust region samples (Gaussian-like around center)
|
|
|
+ for _ in range(n_trust):
|
|
|
+ params = {}
|
|
|
+ for param in self.parameters:
|
|
|
+ center_norm = param.normalize(self.state.trust_region_center[param.name])
|
|
|
+ # Sample with decreasing radius as search progresses
|
|
|
+ radius = self.state.trust_region_radius * max(0.3, 1.0 - self.state.used_budget / self.total_budget)
|
|
|
+ sample_norm = center_norm + self.rng.gauss(0, radius * 0.3)
|
|
|
+ sample_norm = max(0.0, min(1.0, sample_norm))
|
|
|
+ params[param.name] = param.denormalize(sample_norm)
|
|
|
+ candidates.append(params)
|
|
|
+
|
|
|
+ # Global exploration samples
|
|
|
+ candidates.extend(self.latin_hypercube_sample(n_global))
|
|
|
+ else:
|
|
|
+ # No trust region yet: full LHS exploration
|
|
|
+ candidates = self.latin_hypercube_sample(n_candidates)
|
|
|
+
|
|
|
+ # Score and select candidates
|
|
|
+ scored_candidates = []
|
|
|
+ for params in candidates:
|
|
|
+ if self._is_duplicate(params, self.state.points):
|
|
|
+ continue
|
|
|
+
|
|
|
+ # L0 pre-screen
|
|
|
+ report = self.l0_engine.evaluate(params)
|
|
|
+ if not report.feasible:
|
|
|
+ continue # Skip obviously infeasible
|
|
|
+
|
|
|
+ # Distance score (exploration)
|
|
|
+ dist = self._min_distance_to_existing(params, completed_params)
|
|
|
+ dist_score = min(dist / math.sqrt(len(self.parameters)), 1.0)
|
|
|
+
|
|
|
+ # Trust region proximity score (exploitation)
|
|
|
+ trust_score = 0.0
|
|
|
+ if self.state.trust_region_center:
|
|
|
+ dist_to_center_sq = 0.0
|
|
|
+ for param in self.parameters:
|
|
|
+ v1 = param.normalize(params[param.name])
|
|
|
+ v2 = param.normalize(self.state.trust_region_center[param.name])
|
|
|
+ dist_to_center_sq += (v1 - v2) ** 2
|
|
|
+ dist_to_center = math.sqrt(dist_to_center_sq)
|
|
|
+ trust_score = max(0, 1.0 - dist_to_center / self.state.trust_region_radius)
|
|
|
+
|
|
|
+ # Combined score (balance exploration and exploitation)
|
|
|
+ if self.state.trust_region_active:
|
|
|
+ score = 0.4 * dist_score + 0.6 * trust_score
|
|
|
+ else:
|
|
|
+ score = dist_score
|
|
|
+
|
|
|
+ scored_candidates.append((params, report, score))
|
|
|
+
|
|
|
+ # Sort by score and select top batch_size
|
|
|
+ scored_candidates.sort(key=lambda x: x[2], reverse=True)
|
|
|
+ batch_size = min(self.batch_size, self.state.remaining_budget(), len(scored_candidates))
|
|
|
+
|
|
|
+ selected = []
|
|
|
+ for i in range(batch_size):
|
|
|
+ params, report, score = scored_candidates[i]
|
|
|
+ point = SearchPoint(
|
|
|
+ id=len(self.state.points) + i,
|
|
|
+ params=params,
|
|
|
+ status="pending",
|
|
|
+ feasibility_report=report.to_dict(),
|
|
|
+ batch_id=self.state.current_batch,
|
|
|
+ created_at=datetime.now().isoformat(),
|
|
|
+ )
|
|
|
+ selected.append(point)
|
|
|
+
|
|
|
+ self.state.points.extend(selected)
|
|
|
+ self.state.used_budget += len(selected)
|
|
|
+ self.state.current_batch += 1
|
|
|
+ self.state.updated_at = datetime.now().isoformat()
|
|
|
+
|
|
|
+ return selected
|
|
|
+
|
|
|
+ def report_result(self, point_id: int, metrics: Dict[str, float], status: str = "ok") -> None:
|
|
|
+ """Report simulation result for a point.
|
|
|
+
|
|
|
+ Args:
|
|
|
+ point_id: ID of the search point
|
|
|
+ metrics: Dictionary of metric values
|
|
|
+ status: ok / failed
|
|
|
+ """
|
|
|
+ for point in self.state.points:
|
|
|
+ if point.id == point_id:
|
|
|
+ point.metrics = metrics
|
|
|
+ point.status = status
|
|
|
+
|
|
|
+ # Update best feasible point
|
|
|
+ if status == "ok" and self.objective_metric in metrics:
|
|
|
+ value = metrics[self.objective_metric]
|
|
|
+ is_better = (
|
|
|
+ (self.objective_direction == "maximize" and value > self.state.best_objective_value) or
|
|
|
+ (self.objective_direction == "minimize" and value < self.state.best_objective_value)
|
|
|
+ )
|
|
|
+ if is_better or self.state.best_feasible_point is None:
|
|
|
+ self.state.best_objective_value = value
|
|
|
+ self.state.best_feasible_point = point
|
|
|
+
|
|
|
+ # Check convergence
|
|
|
+ self._check_convergence()
|
|
|
+ break
|
|
|
+
|
|
|
+ self.state.updated_at = datetime.now().isoformat()
|
|
|
+
|
|
|
+ def _check_convergence(self) -> None:
|
|
|
+ """Check if search has converged or stalled."""
|
|
|
+ feasible = self.state.get_feasible_points()
|
|
|
+ if len(feasible) < 5:
|
|
|
+ return
|
|
|
+
|
|
|
+ # Check if objective has improved in last N points
|
|
|
+ recent = feasible[-10:] if len(feasible) >= 10 else feasible
|
|
|
+ if len(recent) >= 5:
|
|
|
+ values = [p.metrics.get(self.objective_metric, 0) for p in recent]
|
|
|
+ if self.objective_direction == "maximize":
|
|
|
+ improvement = max(values) - max(values[:-3]) if len(values) > 3 else 0
|
|
|
+ else:
|
|
|
+ improvement = min(values[:-3]) - min(values) if len(values) > 3 else 0
|
|
|
+
|
|
|
+ if improvement < 0.001 and self.state.trust_region_active:
|
|
|
+ # Shrink trust region
|
|
|
+ self.state.trust_region_radius *= 0.7
|
|
|
+ if self.state.trust_region_radius < 0.05:
|
|
|
+ self.state.convergence_status = "converged"
|
|
|
+ self.state.history.append({
|
|
|
+ "event": "converged",
|
|
|
+ "reason": "trust_region_shrunk_below_threshold",
|
|
|
+ "batch": self.state.current_batch,
|
|
|
+ })
|
|
|
+
|
|
|
+ def get_state_summary(self) -> Dict[str, Any]:
|
|
|
+ """Get a summary of current search state."""
|
|
|
+ return {
|
|
|
+ "run_id": self.state.run_id,
|
|
|
+ "search_method": self.state.search_method,
|
|
|
+ "convergence_status": self.state.convergence_status,
|
|
|
+ "total_budget": self.state.total_budget,
|
|
|
+ "used_budget": self.state.used_budget,
|
|
|
+ "remaining_budget": self.state.remaining_budget(),
|
|
|
+ "current_batch": self.state.current_batch,
|
|
|
+ "total_points": len(self.state.points),
|
|
|
+ "pending_points": len(self.state.get_pending_points()),
|
|
|
+ "completed_points": len(self.state.get_completed_points()),
|
|
|
+ "feasible_points": len(self.state.get_feasible_points()),
|
|
|
+ "trust_region_active": self.state.trust_region_active,
|
|
|
+ "trust_region_radius": round(self.state.trust_region_radius, 4),
|
|
|
+ "best_objective_value": self.state.best_objective_value if self.state.best_objective_value != float('inf') else None,
|
|
|
+ "best_point_params": self.state.best_feasible_point.params if self.state.best_feasible_point else None,
|
|
|
+ }
|
|
|
+
|
|
|
+ def export_state(self) -> Dict[str, Any]:
|
|
|
+ """Export full search state for checkpointing."""
|
|
|
+ return {
|
|
|
+ "state": {
|
|
|
+ "run_id": self.state.run_id,
|
|
|
+ "current_batch": self.state.current_batch,
|
|
|
+ "total_budget": self.state.total_budget,
|
|
|
+ "used_budget": self.state.used_budget,
|
|
|
+ "convergence_status": self.state.convergence_status,
|
|
|
+ "trust_region_active": self.state.trust_region_active,
|
|
|
+ "trust_region_center": self.state.trust_region_center,
|
|
|
+ "trust_region_radius": self.state.trust_region_radius,
|
|
|
+ "best_objective_value": self.state.best_objective_value,
|
|
|
+ },
|
|
|
+ "parameters": [
|
|
|
+ {"name": p.name, "min": p.min_value, "max": p.max_value, "step": p.step, "unit": p.unit}
|
|
|
+ for p in self.parameters
|
|
|
+ ],
|
|
|
+ "points": [
|
|
|
+ {
|
|
|
+ "id": p.id,
|
|
|
+ "params": p.params,
|
|
|
+ "status": p.status,
|
|
|
+ "metrics": p.metrics,
|
|
|
+ "batch_id": p.batch_id,
|
|
|
+ "feasible": p.feasibility_report.get("feasible") if p.feasibility_report else None,
|
|
|
+ }
|
|
|
+ for p in self.state.points
|
|
|
+ ],
|
|
|
+ }
|