from __future__ import annotations from dataclasses import dataclass from typing import Final, Literal import numpy as np from constellaration.forward_model import ( ConstellarationMetrics, ConstellarationSettings, forward_model, ) from constellaration.mhd.vmec_settings import VmecPresetSettings from constellaration.geometry import surface_rz_fourier from constellaration.geometry.surface_rz_fourier import SurfaceRZFourier from constellaration.initial_guess import generate_rotating_ellipse from constellaration.problems import GeometricalProblem from fusion_lab.models import ConstraintName, LowDimBoundaryParams ASPECT_RATIO_MAX: Final[float] = 4.0 AVERAGE_TRIANGULARITY_MAX: Final[float] = -0.5 EDGE_IOTA_OVER_NFP_MIN: Final[float] = 0.3 FEASIBILITY_TOLERANCE: Final[float] = 0.01 MAX_POLOIDAL_MODE: Final[int] = 3 MAX_TOROIDAL_MODE: Final[int] = 3 FAILED_FEASIBILITY: Final[float] = 1_000_000.0 FAILED_ELONGATION: Final[float] = 10.0 EvaluationFidelity = Literal["low", "high"] @dataclass(frozen=True) class EvaluationMetrics: max_elongation: float aspect_ratio: float average_triangularity: float edge_iota_over_nfp: float aspect_ratio_violation: float triangularity_violation: float iota_violation: float dominant_constraint: ConstraintName p1_score: float p1_feasibility: float constraints_satisfied: bool vacuum_well: float evaluation_fidelity: EvaluationFidelity evaluation_failed: bool failure_reason: str def build_boundary_from_params( params: LowDimBoundaryParams, *, n_field_periods: int = 3, max_poloidal_mode: int = MAX_POLOIDAL_MODE, max_toroidal_mode: int = MAX_TOROIDAL_MODE, ) -> SurfaceRZFourier: surface = generate_rotating_ellipse( aspect_ratio=params.aspect_ratio, elongation=params.elongation, rotational_transform=params.rotational_transform, n_field_periods=n_field_periods, ) expanded_surface = surface_rz_fourier.set_max_mode_numbers( surface, max_poloidal_mode=max_poloidal_mode, max_toroidal_mode=max_toroidal_mode, ) r_cos = np.asarray(expanded_surface.r_cos, dtype=float).copy() z_sin = np.asarray(expanded_surface.z_sin, dtype=float).copy() center = r_cos.shape[1] // 2 minor_radius = float(r_cos[1, center]) r_cos[2, center] = -params.triangularity_scale * minor_radius r_cos[0, :center] = 0.0 z_sin[0, : center + 1] = 0.0 return SurfaceRZFourier( r_cos=r_cos, z_sin=z_sin, n_field_periods=n_field_periods, is_stellarator_symmetric=True, ) def evaluate_boundary( boundary: SurfaceRZFourier, *, fidelity: EvaluationFidelity = "low", ) -> EvaluationMetrics: settings = _settings_for_fidelity(fidelity) try: metrics, _ = forward_model(boundary, settings=settings) except RuntimeError as error: return _failure_metrics(fidelity=fidelity, failure_reason=str(error)) return _to_evaluation_metrics(metrics, fidelity=fidelity) def _settings_for_fidelity(fidelity: EvaluationFidelity) -> ConstellarationSettings: if fidelity == "high": return ConstellarationSettings( vmec_preset_settings=VmecPresetSettings(fidelity="from_boundary_resolution"), boozer_preset_settings=None, qi_settings=None, turbulent_settings=None, ) return ConstellarationSettings( boozer_preset_settings=None, qi_settings=None, turbulent_settings=None, ) def _to_evaluation_metrics( metrics: ConstellarationMetrics, *, fidelity: EvaluationFidelity, ) -> EvaluationMetrics: problem = GeometricalProblem() constraints_satisfied = problem.is_feasible(metrics) p1_feasibility = float(problem.compute_feasibility(metrics)) objective, minimize_objective = problem.get_objective(metrics) if not minimize_objective: raise ValueError("P1 objective is expected to be minimize-only.") p1_score = _score_from_objective(float(objective)) if constraints_satisfied else 0.0 aspect_ratio_violation, triangularity_violation, iota_violation, dominant_constraint = ( _constraint_violation_metrics(metrics) ) return EvaluationMetrics( max_elongation=float(objective), aspect_ratio=float(metrics.aspect_ratio), average_triangularity=float(metrics.average_triangularity), edge_iota_over_nfp=float(metrics.edge_rotational_transform_over_n_field_periods), aspect_ratio_violation=aspect_ratio_violation, triangularity_violation=triangularity_violation, iota_violation=iota_violation, dominant_constraint=dominant_constraint, p1_score=p1_score, p1_feasibility=p1_feasibility, constraints_satisfied=constraints_satisfied, vacuum_well=float(metrics.vacuum_well), evaluation_fidelity=fidelity, evaluation_failed=False, failure_reason="", ) def _failure_metrics( *, fidelity: EvaluationFidelity, failure_reason: str, ) -> EvaluationMetrics: return EvaluationMetrics( max_elongation=FAILED_ELONGATION, aspect_ratio=0.0, average_triangularity=0.0, edge_iota_over_nfp=0.0, aspect_ratio_violation=0.0, triangularity_violation=0.0, iota_violation=0.0, dominant_constraint="none", p1_score=0.0, p1_feasibility=FAILED_FEASIBILITY, constraints_satisfied=False, vacuum_well=0.0, evaluation_fidelity=fidelity, evaluation_failed=True, failure_reason=failure_reason, ) def _score_from_objective(objective: float) -> float: normalized = min(max((objective - 1.0) / 9.0, 0.0), 1.0) return 1.0 - normalized def _constraint_violation_metrics( metrics: ConstellarationMetrics, ) -> tuple[float, float, float, ConstraintName]: aspect_ratio_violation = max(float(metrics.aspect_ratio) - ASPECT_RATIO_MAX, 0.0) / ( ASPECT_RATIO_MAX ) triangularity_violation = max( float(metrics.average_triangularity) - AVERAGE_TRIANGULARITY_MAX, 0.0, ) / abs(AVERAGE_TRIANGULARITY_MAX) iota_violation = ( max( EDGE_IOTA_OVER_NFP_MIN - abs(float(metrics.edge_rotational_transform_over_n_field_periods)), 0.0, ) / EDGE_IOTA_OVER_NFP_MIN ) dominant_constraint: ConstraintName = "none" dominant_violation = 0.0 constraint_violations: tuple[tuple[ConstraintName, float], ...] = ( ("aspect_ratio", aspect_ratio_violation), ("average_triangularity", triangularity_violation), ("edge_iota_over_nfp", iota_violation), ) for constraint_name, violation in constraint_violations: if violation > dominant_violation: dominant_constraint = constraint_name dominant_violation = violation return ( aspect_ratio_violation, triangularity_violation, iota_violation, dominant_constraint, )