From f13563d7ab771ed583ef63c6c13b52e5c1620ef3 Mon Sep 17 00:00:00 2001 From: Anto01 Date: Tue, 23 Dec 2025 19:47:37 -0500 Subject: [PATCH] feat: Major update - Physics docs, Zernike OPD, insights, NX journals, tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documentation: - Add docs/06_PHYSICS/ with Zernike fundamentals and OPD method docs - Add docs/guides/CMA-ES_EXPLAINED.md optimization guide - Update CLAUDE.md and ATOMIZER_CONTEXT.md with current architecture - Update OP_01_CREATE_STUDY protocol Planning: - Add DYNAMIC_RESPONSE plans for random vibration/PSD support - Add OPTIMIZATION_ENGINE_MIGRATION_PLAN for code reorganization Insights System: - Update design_space, modal_analysis, stress_field, thermal_field insights - Improve error handling and data validation NX Journals: - Add analyze_wfe_zernike.py for Zernike WFE analysis - Add capture_study_images.py for automated screenshots - Add extract_expressions.py and introspect_part.py utilities - Add user_generated_journals/journal_top_view_image_taking.py Tests & Tools: - Add comprehensive Zernike OPD test suite - Add audit_v10 tests for WFE validation - Add tools for Pareto graphs and mirror data extraction - Add migrate_studies_to_topics.py utility Knowledge Base: - Initialize LAC (Learning Atomizer Core) with failure/success patterns Dashboard: - Update Setup.tsx and launch_dashboard.py - Add restart-dev.bat helper script πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .claude/ATOMIZER_CONTEXT.md | 4 +- .../DYNAMIC_RESPONSE_IMPLEMENTATION_PLAN.md | 1084 +++++++++++ .../modules/DYNAMIC_RESPONSE_MASTER_PLAN.md | 1649 +++++++++++++++++ .../OPTIMIZATION_ENGINE_MIGRATION_PLAN.md | 932 ++++++++++ CLAUDE.md | 15 +- .../frontend/src/pages/Setup.tsx | 4 +- atomizer-dashboard/restart-dev.bat | 35 + docs/06_PHYSICS/00_INDEX.md | 59 + .../ZERNIKE_FUNDAMENTALS.md} | 24 +- docs/06_PHYSICS/ZERNIKE_OPD_METHOD.md | 579 ++++++ docs/guides/CMA-ES_EXPLAINED.md | 212 +++ .../operations/OP_01_CREATE_STUDY.md | 65 + .../lac/session_insights/failure.jsonl | 5 + .../session_insights/success_pattern.jsonl | 3 + launch_dashboard.py | 2 +- nx_journals/analyze_wfe_zernike.py | 233 +++ nx_journals/capture_study_images.py | 184 ++ nx_journals/extract_expressions.py | 111 ++ nx_journals/extract_expressions_standalone.py | 96 + nx_journals/introspect_part.py | 620 +++++++ nx_journals/list_expressions_simple.py | 55 + nx_journals/test_write.py | 11 + .../journal_top_view_image_taking.py | 229 +++ optimization_engine/insights/design_space.py | 1 + .../insights/modal_analysis.py | 1 + optimization_engine/insights/stress_field.py | 1 + optimization_engine/insights/thermal_field.py | 1 + temp_compare.py | 74 + tests/audit_v10_fix.py | 74 + tests/audit_v10_method_diff.py | 72 + tests/audit_v10_wfe.py | 143 ++ tests/check_api_routes.py | 20 + tests/debug_figure_coords.py | 83 + tests/debug_insights.py | 50 + tests/test_insights_import.py | 21 + {tools => tests}/test_zernike_import.py | 0 tests/test_zernike_insight.py | 31 + tests/test_zernike_opd_comparison.py | 95 + tests/test_zernike_opd_with_prescription.py | 199 ++ tools/create_pareto_graphs.py | 388 ++++ tools/extract_all_mirror_data.py | 192 ++ tools/extract_mirror_optical_specs.py | 294 +++ tools/migrate_studies_to_topics.py | 155 ++ 43 files changed, 8098 insertions(+), 8 deletions(-) create mode 100644 .claude/skills/modules/DYNAMIC_RESPONSE_IMPLEMENTATION_PLAN.md create mode 100644 .claude/skills/modules/DYNAMIC_RESPONSE_MASTER_PLAN.md create mode 100644 .claude/skills/modules/OPTIMIZATION_ENGINE_MIGRATION_PLAN.md create mode 100644 atomizer-dashboard/restart-dev.bat create mode 100644 docs/06_PHYSICS/00_INDEX.md rename docs/{ZERNIKE_INTEGRATION.md => 06_PHYSICS/ZERNIKE_FUNDAMENTALS.md} (87%) create mode 100644 docs/06_PHYSICS/ZERNIKE_OPD_METHOD.md create mode 100644 docs/guides/CMA-ES_EXPLAINED.md create mode 100644 knowledge_base/lac/session_insights/failure.jsonl create mode 100644 knowledge_base/lac/session_insights/success_pattern.jsonl create mode 100644 nx_journals/analyze_wfe_zernike.py create mode 100644 nx_journals/capture_study_images.py create mode 100644 nx_journals/extract_expressions.py create mode 100644 nx_journals/extract_expressions_standalone.py create mode 100644 nx_journals/introspect_part.py create mode 100644 nx_journals/list_expressions_simple.py create mode 100644 nx_journals/test_write.py create mode 100644 nx_journals/user_generated_journals/journal_top_view_image_taking.py create mode 100644 temp_compare.py create mode 100644 tests/audit_v10_fix.py create mode 100644 tests/audit_v10_method_diff.py create mode 100644 tests/audit_v10_wfe.py create mode 100644 tests/check_api_routes.py create mode 100644 tests/debug_figure_coords.py create mode 100644 tests/debug_insights.py create mode 100644 tests/test_insights_import.py rename {tools => tests}/test_zernike_import.py (100%) create mode 100644 tests/test_zernike_insight.py create mode 100644 tests/test_zernike_opd_comparison.py create mode 100644 tests/test_zernike_opd_with_prescription.py create mode 100644 tools/create_pareto_graphs.py create mode 100644 tools/extract_all_mirror_data.py create mode 100644 tools/extract_mirror_optical_specs.py create mode 100644 tools/migrate_studies_to_topics.py diff --git a/.claude/ATOMIZER_CONTEXT.md b/.claude/ATOMIZER_CONTEXT.md index 72d60dd2..31797bf5 100644 --- a/.claude/ATOMIZER_CONTEXT.md +++ b/.claude/ATOMIZER_CONTEXT.md @@ -136,9 +136,11 @@ studies/{geometry_type}/{study_name}/ | E3 | Von Mises Stress | `extract_solid_stress()` | **Specify element_type!** | | E4 | BDF Mass | `extract_mass_from_bdf()` | kg | | E5 | CAD Mass | `extract_mass_from_expression()` | kg | -| E8-10 | Zernike WFE | `extract_zernike_*()` | nm (mirrors) | +| E8-10 | Zernike WFE (standard) | `extract_zernike_*()` | nm (mirrors) | | E12-14 | Phase 2 | Principal stress, strain energy, SPC forces | | E15-18 | Phase 3 | Temperature, heat flux, modal mass | +| E20 | Zernike Analytic | `extract_zernike_analytic()` | nm (parabola-based) | +| E22 | **Zernike OPD** | `extract_zernike_opd()` | nm (**RECOMMENDED**) | **Critical**: For stress extraction, specify element type: - Shell (CQUAD4): `element_type='cquad4'` diff --git a/.claude/skills/modules/DYNAMIC_RESPONSE_IMPLEMENTATION_PLAN.md b/.claude/skills/modules/DYNAMIC_RESPONSE_IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..991ca57c --- /dev/null +++ b/.claude/skills/modules/DYNAMIC_RESPONSE_IMPLEMENTATION_PLAN.md @@ -0,0 +1,1084 @@ +# Dynamic Response Processor - Implementation Plan + +## Atomizer Integration Assessment & Revised Roadmap + +**Document Version**: 1.1 +**Created**: 2025-12-22 +**Revised**: 2025-12-22 +**Status**: Ready for Implementation +**Based On**: DYNAMIC_RESPONSE_MASTER_PLAN.md + +--- + +## Executive Summary + +After analyzing the Master Plan against Atomizer's current architecture, this document provides a **refined implementation plan** that: + +1. Aligns with established patterns (extractors, insights, registry) +2. Leverages existing code (modal_mass extractor, modal_analysis insight) +3. Creates the new `processors/` layer for algorithm code +4. Integrates seamlessly with dashboard and protocols + +**Key Finding**: The Master Plan is well-designed. This document reorganizes it to fit Atomizer's conventions and identifies reuse opportunities. + +--- + +## 1. Architecture Alignment + +### Current Atomizer Layers + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ USER INTERFACE β”‚ +β”‚ (Dashboard / CLI / run_optimization.py) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ EXTRACTORS β”‚ +β”‚ optimization_engine/extractors/extract_*.py β”‚ +β”‚ - Simple function interface: extract_X(file) β†’ dict β”‚ +β”‚ - Called from objective functions β”‚ +β”‚ - Example: extract_frequency(), extract_modal_mass() β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ PROCESSORS (NEW) β”‚ +β”‚ optimization_engine/processors/dynamic_response/ β”‚ +β”‚ - Algorithm implementations (modal superposition, FRF) β”‚ +β”‚ - Class-based with rich state β”‚ +β”‚ - Called by extractors β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ INSIGHTS β”‚ +β”‚ optimization_engine/insights/*.py β”‚ +β”‚ - Visualization classes with @register_insight β”‚ +β”‚ - Generate HTML/Plotly for dashboard β”‚ +β”‚ - Example: ModalInsight, StressFieldInsight β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Proposed Integration + +``` +optimization_engine/ +β”œβ”€β”€ extractors/ +β”‚ β”œβ”€β”€ __init__.py # Add new exports +β”‚ β”œβ”€β”€ extract_modal_mass.py # EXISTING - F06 parsing βœ“ +β”‚ β”œβ”€β”€ extract_random_response.py # NEW - High-level random vib interface +β”‚ β”œβ”€β”€ extract_sine_response.py # NEW - Sine sweep interface +β”‚ └── extract_dynamic_stress.py # NEW - Dynamic stress interface +β”‚ +β”œβ”€β”€ processors/ # NEW DIRECTORY +β”‚ β”œβ”€β”€ __init__.py +β”‚ └── dynamic_response/ +β”‚ β”œβ”€β”€ __init__.py # Public API +β”‚ β”œβ”€β”€ modal_database.py # ModeShape, ModalDatabase +β”‚ β”œβ”€β”€ transfer_functions.py # TransferFunctionEngine +β”‚ β”œβ”€β”€ random_vibration.py # RandomVibrationProcessor +β”‚ β”œβ”€β”€ sine_sweep.py # SineSweepProcessor (Phase 4) +β”‚ β”œβ”€β”€ psd_profiles.py # PSD definitions +β”‚ └── utils/ +β”‚ β”œβ”€β”€ __init__.py +β”‚ β”œβ”€β”€ frequency.py # Frequency array utilities +β”‚ β”œβ”€β”€ integration.py # Spectral integration +β”‚ └── statistics.py # Peak factors +β”‚ +β”œβ”€β”€ insights/ +β”‚ β”œβ”€β”€ __init__.py # Add new registration +β”‚ β”œβ”€β”€ modal_analysis.py # EXISTING - Mode shapes βœ“ +β”‚ └── dynamic_response.py # NEW - Grms, PSD plots +β”‚ +└── hooks/ + └── dynamic_response/ # NEW + β”œβ”€β”€ pre_analysis.py # Validate modal data + └── post_analysis.py # Cache results +``` + +--- + +## 2. Code Reuse Opportunities + +### Existing Assets to Leverage + +| Asset | Location | Reuse Strategy | +|-------|----------|----------------| +| Modal mass extraction | `extract_modal_mass.py` | Call from `ModalDatabase.from_op2_f06()` | +| F06 MEFFMASS parsing | `_parse_modal_effective_mass()` | Import directly | +| Participation factors | `_parse_participation_factors()` | Import directly | +| OP2 reading | `pyNastran` (installed) | Use for eigenvectors | +| Modal insight base | `modal_analysis.py` | Extend or reference patterns | +| Insight registry | `@register_insight` decorator | Use for DynamicResponseInsight | +| Extractor patterns | All `extract_*.py` | Follow return dict pattern | + +### Key Code Connections + +```python +# In processors/dynamic_response/modal_database.py +from optimization_engine.extractors.extract_modal_mass import ( + extract_modal_mass, + _parse_modal_effective_mass, # Internal but useful +) + +class ModalDatabase: + @classmethod + def from_op2_f06(cls, op2_file, f06_file, **options): + # Use existing extractor for participation factors + modal_data = extract_modal_mass(f06_file, mode=None) + + # Extract eigenvectors from OP2 + from pyNastran.op2.op2 import OP2 + op2 = OP2() + op2.read_op2(str(op2_file)) + + # Build ModeShape objects combining both sources + ... +``` + +--- + +## 3. Implementation Phases (Revised) + +### Phase 1: Core Infrastructure (3-4 days) + +**Goal**: Create processor foundation with test-validated components. + +#### 1.1 Create Directory Structure +```bash +mkdir -p optimization_engine/processors/dynamic_response/utils +touch optimization_engine/processors/__init__.py +touch optimization_engine/processors/dynamic_response/__init__.py +touch optimization_engine/processors/dynamic_response/utils/__init__.py +``` + +#### 1.2 Implement Data Classes + +**File**: `optimization_engine/processors/dynamic_response/modal_database.py` + +```python +""" +Modal Database - Central storage for modal analysis data. + +Combines: +- Eigenvectors from OP2 (pyNastran) +- Participation factors from F06 (existing extract_modal_mass) +- User-specified damping + +Provides efficient access for dynamic calculations. +""" + +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple +from pathlib import Path +import numpy as np +import logging + +logger = logging.getLogger(__name__) + + +@dataclass +class ModeShape: + """Single mode shape container.""" + + mode_number: int + frequency: float # Hz + damping_ratio: float = 0.02 + + # Modal properties (from F06) + generalized_mass: float = 1.0 + participation_x: float = 0.0 + participation_y: float = 0.0 + participation_z: float = 0.0 + effective_mass_x: float = 0.0 + effective_mass_y: float = 0.0 + effective_mass_z: float = 0.0 + + # Eigenvector at nodes (from OP2) + # Dict[node_id, np.array([tx, ty, tz, rx, ry, rz])] + eigenvector: Dict[int, np.ndarray] = field(default_factory=dict) + + # Modal stress shapes (optional, for stress recovery) + # Dict[element_id, np.array([sxx, syy, szz, sxy, syz, sxz, vm])] + stress_shapes: Dict[int, np.ndarray] = field(default_factory=dict) + + @property + def omega(self) -> float: + """Angular frequency (rad/s).""" + return 2 * np.pi * self.frequency + + @property + def omega_squared(self) -> float: + return self.omega ** 2 + + @property + def quality_factor(self) -> float: + """Q = 1/(2ΞΆ)""" + return 1 / (2 * self.damping_ratio) if self.damping_ratio > 0 else np.inf + + def get_participation(self, direction: str) -> float: + """Get participation factor for direction ('x', 'y', 'z').""" + return getattr(self, f'participation_{direction}', 0.0) + + def get_effective_mass(self, direction: str) -> float: + return getattr(self, f'effective_mass_{direction}', 0.0) + + +class ModalDatabase: + """ + Central modal data storage with lazy loading. + + Combines OP2 (eigenvectors) and F06 (participation) data. + Provides caching via HDF5 for optimization loops. + """ + + def __init__( + self, + modes: List[ModeShape], + total_mass: float, + model_name: str = "unnamed" + ): + self.modes = modes + self.total_mass = total_mass + self.model_name = model_name + self._cache = {} + + @property + def n_modes(self) -> int: + return len(self.modes) + + @property + def frequency_range(self) -> Tuple[float, float]: + if not self.modes: + return (0.0, 0.0) + freqs = [m.frequency for m in self.modes] + return (min(freqs), max(freqs)) + + def get_frequencies(self) -> np.ndarray: + return np.array([m.frequency for m in self.modes]) + + def get_participation_factors(self, direction: str) -> np.ndarray: + return np.array([m.get_participation(direction) for m in self.modes]) + + def get_effective_masses(self, direction: str) -> np.ndarray: + return np.array([m.get_effective_mass(direction) for m in self.modes]) + + def get_cumulative_mass_fraction(self, direction: str) -> np.ndarray: + """Cumulative effective mass fraction for mode selection.""" + eff_masses = self.get_effective_masses(direction) + return np.cumsum(eff_masses) / self.total_mass + + def modes_for_mass_fraction(self, fraction: float, direction: str) -> int: + """Number of modes needed to capture given mass fraction.""" + cumulative = self.get_cumulative_mass_fraction(direction) + indices = np.where(cumulative >= fraction)[0] + return int(indices[0] + 1) if len(indices) > 0 else self.n_modes + + @classmethod + def from_op2_f06( + cls, + op2_file: Path, + f06_file: Optional[Path] = None, + damping: float = 0.02, + node_ids: Optional[List[int]] = None, + element_ids: Optional[List[int]] = None, + ) -> 'ModalDatabase': + """ + Build ModalDatabase from NX Nastran results. + + Args: + op2_file: Path to OP2 with eigenvectors + f06_file: Path to F06 with MEFFMASS (auto-detected if None) + damping: Default damping ratio for all modes + node_ids: Only extract eigenvectors at these nodes + element_ids: Only extract stress shapes for these elements + """ + from pyNastran.op2.op2 import OP2 + from optimization_engine.extractors.extract_modal_mass import extract_modal_mass + + op2_path = Path(op2_file) + + # Auto-detect F06 + if f06_file is None: + f06_path = op2_path.with_suffix('.f06') + if not f06_path.exists(): + f06_path = None + else: + f06_path = Path(f06_file) + + # Extract participation factors from F06 + modal_mass_data = None + if f06_path and f06_path.exists(): + result = extract_modal_mass(f06_path, mode=None) + if result['success']: + modal_mass_data = result.get('modes', []) + + # Read OP2 for eigenvectors + logger.info(f"Reading OP2: {op2_path}") + op2 = OP2() + op2.read_op2(str(op2_path)) + + if not op2.eigenvectors: + raise ValueError(f"No eigenvectors found in {op2_path}") + + # Get total mass + total_mass = 1.0 # Default + if hasattr(op2, 'grid_point_weight') and op2.grid_point_weight: + # Try to get from GPWG + for gpwg in op2.grid_point_weight.values(): + total_mass = float(gpwg.mass[0]) + break + + # Build ModeShape objects + modes = [] + + for key, eig in op2.eigenvectors.items(): + n_modes = len(eig.modes) if hasattr(eig, 'modes') else 0 + + for i in range(n_modes): + mode_num = int(eig.modes[i]) + freq = float(eig.cycles[i]) if hasattr(eig, 'cycles') else 0.0 + + # Get eigenvector data + eigenvector = {} + if node_ids is not None: + for nid in node_ids: + if nid in eig.node_gridtype: + idx = np.where(eig.node_gridtype[:, 0] == nid)[0] + if len(idx) > 0: + eigenvector[nid] = eig.data[i, idx[0], :] + else: + # Store all nodes (memory intensive for large models) + for j, (nid, _) in enumerate(eig.node_gridtype): + eigenvector[int(nid)] = eig.data[i, j, :] + + # Merge with F06 data + participation = {'x': 0.0, 'y': 0.0, 'z': 0.0} + effective = {'x': 0.0, 'y': 0.0, 'z': 0.0} + gen_mass = 1.0 + + if modal_mass_data and mode_num <= len(modal_mass_data): + mm = modal_mass_data[mode_num - 1] + participation = { + 'x': mm.get('participation_x', 0.0) or 0.0, + 'y': mm.get('participation_y', 0.0) or 0.0, + 'z': mm.get('participation_z', 0.0) or 0.0, + } + effective = { + 'x': mm.get('mass_x', 0.0) or 0.0, + 'y': mm.get('mass_y', 0.0) or 0.0, + 'z': mm.get('mass_z', 0.0) or 0.0, + } + + mode_shape = ModeShape( + mode_number=mode_num, + frequency=freq, + damping_ratio=damping, + generalized_mass=gen_mass, + participation_x=participation['x'], + participation_y=participation['y'], + participation_z=participation['z'], + effective_mass_x=effective['x'], + effective_mass_y=effective['y'], + effective_mass_z=effective['z'], + eigenvector=eigenvector, + ) + modes.append(mode_shape) + + break # Only process first subcase + + logger.info(f"Built ModalDatabase: {len(modes)} modes, " + f"freq range {modes[0].frequency:.1f}-{modes[-1].frequency:.1f} Hz") + + return cls(modes=modes, total_mass=total_mass, model_name=op2_path.stem) + + def summary(self) -> str: + """Human-readable summary.""" + lines = [ + f"ModalDatabase: {self.model_name}", + f" Modes: {self.n_modes}", + f" Frequency range: {self.frequency_range[0]:.1f} - {self.frequency_range[1]:.1f} Hz", + f" Total mass: {self.total_mass:.3f} kg", + ] + return "\n".join(lines) +``` + +#### 1.3 Implement Transfer Functions + +**File**: `optimization_engine/processors/dynamic_response/transfer_functions.py` + +```python +""" +Transfer Function Engine - FRF computation from modal data. + +Computes: +- Single-mode FRF (SDOF transfer function) +- Multi-mode combination (modal superposition) +- Base excitation transmissibility +""" + +import numpy as np +from typing import Optional, Tuple +from .modal_database import ModalDatabase, ModeShape + + +class TransferFunctionEngine: + """ + Frequency response function computation engine. + + Uses modal superposition to compute dynamic response + transfer functions from modal analysis data. + """ + + def __init__(self, modal_db: ModalDatabase): + self.modal_db = modal_db + + def single_mode_frf( + self, + frequencies: np.ndarray, + mode: ModeShape, + output_type: str = 'displacement' + ) -> np.ndarray: + """ + Compute FRF for a single mode. + + H(f) = 1 / [(Ο‰_nΒ² - ω²) + jΒ·2Β·ΞΆΒ·Ο‰_nΒ·Ο‰] + + Args: + frequencies: Frequency array (Hz) + mode: ModeShape object + output_type: 'displacement', 'velocity', or 'acceleration' + + Returns: + Complex FRF array + """ + omega = 2 * np.pi * frequencies + omega_n = mode.omega + zeta = mode.damping_ratio + + # Base displacement FRF + H = 1.0 / ((omega_n**2 - omega**2) + 2j * zeta * omega_n * omega) + + if output_type == 'velocity': + H = H * (1j * omega) + elif output_type == 'acceleration': + H = H * (-omega**2) + + return H + + def base_excitation_frf( + self, + frequencies: np.ndarray, + direction: str = 'z', + output_type: str = 'acceleration', + node_id: Optional[int] = None, + n_modes: Optional[int] = None, + ) -> np.ndarray: + """ + Compute FRF for base excitation (seismic/vibration). + + Combines modal contributions: + H_total(f) = Ξ£α΅’ Lα΅’ Β· Ο†α΅’ Β· Hα΅’(f) / mα΅’ + + Args: + frequencies: Frequency array (Hz) + direction: Excitation direction ('x', 'y', 'z') + output_type: 'displacement', 'velocity', 'acceleration' + node_id: Specific output node (None = use participation only) + n_modes: Number of modes (None = 95% mass fraction) + + Returns: + Complex FRF (transmissibility for acceleration output) + """ + # Determine number of modes + if n_modes is None: + n_modes = self.modal_db.modes_for_mass_fraction(0.95, direction) + n_modes = min(n_modes, self.modal_db.n_modes) + + omega = 2 * np.pi * frequencies + H_total = np.zeros(len(frequencies), dtype=complex) + + for i in range(n_modes): + mode = self.modal_db.modes[i] + + # Participation factor for this direction + L = mode.get_participation(direction) + if L == 0: + continue + + # Mode shape at output location + if node_id is not None and node_id in mode.eigenvector: + dir_idx = {'x': 0, 'y': 1, 'z': 2}.get(direction, 2) + phi = mode.eigenvector[node_id][dir_idx] + else: + # Use participation as proxy for overall response + phi = 1.0 + + # Single mode FRF + H_mode = self.single_mode_frf(frequencies, mode, 'displacement') + + # Add modal contribution + H_total += L * phi * H_mode / mode.generalized_mass + + # Convert to requested output type + if output_type == 'velocity': + H_total = H_total * (1j * omega) + elif output_type == 'acceleration': + # For base excitation: transmissibility = 1 + ω²·H_disp + H_total = 1.0 + (-omega**2) * H_total + + return H_total + + def transmissibility( + self, + frequencies: np.ndarray, + direction: str = 'z', + n_modes: Optional[int] = None, + ) -> Tuple[np.ndarray, np.ndarray]: + """ + Compute acceleration transmissibility magnitude and phase. + + Returns: + (magnitude, phase_degrees) + """ + H = self.base_excitation_frf( + frequencies, direction, 'acceleration', n_modes=n_modes + ) + return np.abs(H), np.angle(H, deg=True) +``` + +#### 1.4 Implement PSD Profiles + +**File**: `optimization_engine/processors/dynamic_response/psd_profiles.py` + +(Use content from Master Plan Section 2 - already well-designed) + +#### 1.5 Create Tests + +**File**: `tests/test_dynamic_response/test_transfer_functions.py` + +```python +""" +Transfer Function Tests + +Validates FRF computations against analytical solutions. +""" + +import numpy as np +import pytest +from optimization_engine.processors.dynamic_response.modal_database import ModeShape, ModalDatabase +from optimization_engine.processors.dynamic_response.transfer_functions import TransferFunctionEngine + + +def test_sdof_resonance_amplification(): + """At resonance, |H| β‰ˆ Q for lightly damped SDOF.""" + mode = ModeShape( + mode_number=1, + frequency=100.0, # Hz + damping_ratio=0.02, # 2% damping β†’ Q = 25 + ) + db = ModalDatabase(modes=[mode], total_mass=1.0) + engine = TransferFunctionEngine(db) + + # Evaluate at resonance + f_res = np.array([100.0]) + H = engine.single_mode_frf(f_res, mode, 'displacement') + + # Expected: |H| = 1/(2*zeta*omega_n^2) at resonance + omega_n = 2 * np.pi * 100 + expected_mag = 1 / (2 * 0.02 * omega_n**2) + + assert np.isclose(np.abs(H[0]), expected_mag, rtol=0.01) + + +def test_transmissibility_unity_low_freq(): + """Transmissibility β†’ 1 at low frequencies (rigid body).""" + mode = ModeShape(mode_number=1, frequency=100.0, damping_ratio=0.02) + db = ModalDatabase(modes=[mode], total_mass=1.0) + engine = TransferFunctionEngine(db) + + low_freq = np.array([1.0]) # Well below resonance + mag, _ = engine.transmissibility(low_freq) + + assert np.isclose(mag[0], 1.0, rtol=0.1) + + +def test_transmissibility_isolation_high_freq(): + """Transmissibility β†’ 0 at high frequencies (isolation).""" + mode = ModeShape(mode_number=1, frequency=100.0, damping_ratio=0.02) + mode.participation_z = 1.0 + mode.effective_mass_z = 1.0 + db = ModalDatabase(modes=[mode], total_mass=1.0) + engine = TransferFunctionEngine(db) + + high_freq = np.array([1000.0]) # Well above resonance + mag, _ = engine.transmissibility(high_freq, direction='z') + + # Should be much less than 1 + assert mag[0] < 0.1 +``` + +--- + +### Phase 2: Random Vibration Processor (3-4 days) + +**Goal**: Complete random vibration response computation with Miles' equation validation. + +#### 2.1 RandomVibrationProcessor + +**File**: `optimization_engine/processors/dynamic_response/random_vibration.py` + +```python +""" +Random Vibration Processor + +Computes Grms, peak response, and stress from PSD input using modal superposition. +""" + +from dataclasses import dataclass +from typing import Dict, Any, Optional, List, Tuple +import numpy as np +from scipy.integrate import trapezoid + +from .modal_database import ModalDatabase +from .transfer_functions import TransferFunctionEngine +from .psd_profiles import PSDProfile + + +@dataclass +class RandomVibrationResult: + """Container for random vibration results.""" + rms_acceleration: float # Grms + peak_acceleration: float # G (k-sigma) + rms_displacement: float # mm + peak_displacement: float # mm + dominant_mode: int # Mode with highest contribution + modal_contributions: Dict[int, float] # Mode β†’ % contribution + response_psd: Tuple[np.ndarray, np.ndarray] # (frequencies, psd) + + +class RandomVibrationProcessor: + """ + Random vibration response via modal superposition. + + Replaces SOL 111/112 with analytical computation. + """ + + def __init__( + self, + modal_db: ModalDatabase, + default_damping: float = 0.02 + ): + self.modal_db = modal_db + self.default_damping = default_damping + self.frf_engine = TransferFunctionEngine(modal_db) + + def compute_response( + self, + psd_profile: PSDProfile, + direction: str = 'z', + node_id: Optional[int] = None, + n_modes: Optional[int] = None, + frequency_resolution: int = 500, + peak_sigma: float = 3.0, + output_type: str = 'acceleration' + ) -> RandomVibrationResult: + """ + Compute full random vibration response. + + Args: + psd_profile: Input PSD specification + direction: Excitation direction ('x', 'y', 'z') + node_id: Output location (None = overall) + n_modes: Number of modes (None = auto 95% mass) + frequency_resolution: Points in frequency array + peak_sigma: Sigma level for peak (3.0 = 3-sigma) + output_type: 'acceleration' or 'displacement' + + Returns: + RandomVibrationResult with all metrics + """ + # Generate frequency array (log spacing recommended for PSD) + f_min = max(psd_profile.freq_min, 1.0) + f_max = psd_profile.freq_max + frequencies = np.logspace( + np.log10(f_min), + np.log10(f_max), + frequency_resolution + ) + + # Compute transfer function + H = self.frf_engine.base_excitation_frf( + frequencies, direction, output_type, node_id, n_modes + ) + + # Interpolate input PSD to our frequency array + input_psd = psd_profile.interpolate(frequencies) + + # Response PSD: S_y = |H|Β² Γ— S_x + response_psd = np.abs(H)**2 * input_psd + + # RMS via integration + mean_square = trapezoid(response_psd, frequencies) + rms = np.sqrt(mean_square) + peak = peak_sigma * rms + + # Also compute displacement if acceleration was requested + if output_type == 'acceleration': + H_disp = self.frf_engine.base_excitation_frf( + frequencies, direction, 'displacement', node_id, n_modes + ) + resp_psd_disp = np.abs(H_disp)**2 * input_psd + rms_disp = np.sqrt(trapezoid(resp_psd_disp, frequencies)) + peak_disp = peak_sigma * rms_disp + + rms_accel = rms + peak_accel = peak + else: + rms_disp = rms + peak_disp = peak + rms_accel = 0.0 + peak_accel = 0.0 + + # Modal contribution analysis + contributions = self._compute_modal_contributions( + frequencies, input_psd, direction, n_modes + ) + + dominant_mode = max(contributions, key=contributions.get) if contributions else 1 + + return RandomVibrationResult( + rms_acceleration=rms_accel, + peak_acceleration=peak_accel, + rms_displacement=rms_disp * 1000, # Convert to mm + peak_displacement=peak_disp * 1000, + dominant_mode=dominant_mode, + modal_contributions=contributions, + response_psd=(frequencies, response_psd), + ) + + def _compute_modal_contributions( + self, + frequencies: np.ndarray, + input_psd: np.ndarray, + direction: str, + n_modes: Optional[int] + ) -> Dict[int, float]: + """Compute contribution of each mode to total response.""" + if n_modes is None: + n_modes = self.modal_db.modes_for_mass_fraction(0.95, direction) + n_modes = min(n_modes, self.modal_db.n_modes) + + contributions = {} + total = 0.0 + + for i in range(n_modes): + mode = self.modal_db.modes[i] + L = mode.get_participation(direction) + + # Single mode response + H_mode = self.frf_engine.single_mode_frf(frequencies, mode, 'acceleration') + resp_psd = np.abs(H_mode * L)**2 * input_psd + mode_ms = trapezoid(resp_psd, frequencies) + + contributions[mode.mode_number] = mode_ms + total += mode_ms + + # Normalize to percentages + if total > 0: + contributions = {k: v/total * 100 for k, v in contributions.items()} + + return contributions + + def compute_grms( + self, + psd_profile: PSDProfile, + direction: str = 'z', + node_id: Optional[int] = None + ) -> float: + """Quick Grms extraction for optimization objective.""" + result = self.compute_response(psd_profile, direction, node_id) + return result.rms_acceleration + + def compute_peak_g( + self, + psd_profile: PSDProfile, + direction: str = 'z', + sigma: float = 3.0 + ) -> float: + """Quick peak G extraction for constraint.""" + result = self.compute_response(psd_profile, direction, peak_sigma=sigma) + return result.peak_acceleration + + +def miles_equation(f_n: float, zeta: float, psd_level: float) -> float: + """ + Miles' equation for SDOF with white noise. + + Grms = sqrt(Ο€/2 Γ— f_n Γ— Q Γ— PSD) + + Args: + f_n: Natural frequency (Hz) + zeta: Damping ratio + psd_level: PSD level (GΒ²/Hz) + + Returns: + Grms (G) + """ + Q = 1 / (2 * zeta) + return np.sqrt(np.pi / 2 * f_n * Q * psd_level) +``` + +#### 2.2 Tests for Random Vibration + +```python +def test_miles_equation_validation(): + """Random vibration Grms matches Miles' equation for SDOF.""" + f_n = 100.0 + zeta = 0.02 + psd_level = 0.04 # GΒ²/Hz + + # Analytical + grms_miles = miles_equation(f_n, zeta, psd_level) + + # Via processor + mode = ModeShape( + mode_number=1, + frequency=f_n, + damping_ratio=zeta, + participation_z=1.0, + effective_mass_z=1.0, + ) + db = ModalDatabase(modes=[mode], total_mass=1.0) + + psd = PSDProfile.flat(psd_level, freq_min=10, freq_max=1000) + processor = RandomVibrationProcessor(db) + grms_processor = processor.compute_grms(psd, 'z') + + assert np.isclose(grms_processor, grms_miles, rtol=0.05) # 5% tolerance +``` + +--- + +### Phase 3: Atomizer Integration (2-3 days) + +**Goal**: Connect to extractors, insights, and dashboard. + +#### 3.1 Create High-Level Extractor + +**File**: `optimization_engine/extractors/extract_random_response.py` + +(Use content from Master Plan with adjustments to import from processors/) + +#### 3.2 Update Extractor Registry + +**File**: `optimization_engine/extractors/__init__.py` (add to existing) + +```python +# Phase 5: Dynamic Response (2025-12-XX) +from .extract_random_response import ( + extract_random_response, + extract_random_stress, + get_grms, + get_peak_g, + get_peak_stress, +) + +# Add to __all__ +__all__ += [ + 'extract_random_response', + 'extract_random_stress', + 'get_grms', + 'get_peak_g', + 'get_peak_stress', +] +``` + +#### 3.3 Create Dynamic Response Insight + +**File**: `optimization_engine/insights/dynamic_response.py` + +```python +""" +Dynamic Response Insight + +Visualizes random vibration and sine sweep response data. +""" + +from pathlib import Path +from typing import Optional +import numpy as np + +from .base import StudyInsight, InsightConfig, InsightResult, register_insight + + +@register_insight +class DynamicResponseInsight(StudyInsight): + """ + Dynamic response visualization. + + Shows: + - Input vs Output PSD (log-log) + - Transmissibility magnitude/phase + - Modal contribution bar chart + - Grms history across trials + """ + + insight_type = "dynamic_response" + name = "Dynamic Response Analysis" + description = "Random vibration and frequency response visualization" + category = "dynamics" + applicable_to = ["random_vib", "dynamics", "modal", "vibration"] + required_files = ["*.op2"] + + # ... implementation following existing insight patterns +``` + +--- + +### Phase 4: Sine Sweep & Advanced Features (Future) + +Defer to after Phase 3 is validated: +- Sine sweep processor +- Shock response spectrum +- Multi-axis excitation +- Fatigue from random + +--- + +## 4. Protocol Documentation + +### New Protocol: SYS_17_DYNAMIC_RESPONSE.md + +Create `docs/protocols/system/SYS_17_DYNAMIC_RESPONSE.md`: + +```markdown +# SYS_17: Dynamic Response Processor + +## Overview +Modal superposition-based dynamic response for optimization. + +## Capabilities +- Random vibration (Grms, peak acceleration, stress) +- Sine sweep (magnitude, phase, resonance search) +- Shock response spectrum (future) + +## Quick Reference + +| Function | Description | Output | +|----------|-------------|--------| +| `extract_random_response()` | Full random vib metrics | dict | +| `get_grms()` | Quick Grms for objective | float | +| `get_peak_g()` | Quick peak G for constraint | float | + +## Example Usage + +```python +from optimization_engine.extractors import extract_random_response + +# In optimization objective +result = extract_random_response( + op2_file='model.op2', + psd_profile='GEVS', + direction='z', + damping=0.02 +) + +objective_value = result['grms'] +constraint_value = result['peak_acceleration'] +``` + +## PSD Profiles Available +- GEVS (NASA standard) +- MIL-STD-810G variants (CAT1, CAT4, CAT7, CAT24) +- ESA PSS-01-401 (qualification, acceptance) +- Custom from CSV + +## Validation +- Miles' equation (SDOF) - <1% error +- SOL 111 comparison - <5% error target +``` + +### Update SYS_12 Extractor Library + +Add entries for new extractors: + +| ID | Name | Function | Input | Output | +|----|------|----------|-------|--------| +| E23 | Random Response | `extract_random_response()` | .op2/.f06 + PSD | dict | +| E24 | Dynamic Stress | `extract_random_stress()` | .op2/.f06 + PSD | MPa | +| E25 | Quick Grms | `get_grms()` | .op2 + PSD | G | + +--- + +## 5. Implementation Checklist + +### Phase 1 Checklist +- [ ] Create `optimization_engine/processors/` directory structure +- [ ] Implement `modal_database.py` with ModeShape + ModalDatabase +- [ ] Implement `transfer_functions.py` with TransferFunctionEngine +- [ ] Implement `psd_profiles.py` with PSDProfile + STANDARD_PROFILES +- [ ] Create `utils/` with frequency, integration, statistics helpers +- [ ] Write unit tests for FRF (SDOF validation) +- [ ] Test ModalDatabase.from_op2_f06() with real NX data + +### Phase 2 Checklist +- [ ] Implement `random_vibration.py` with RandomVibrationProcessor +- [ ] Implement modal contribution analysis +- [ ] Write Miles' equation validation test +- [ ] Add stress recovery (optional for Phase 2) +- [ ] Integration test with sample study + +### Phase 3 Checklist +- [ ] Create `extract_random_response.py` in extractors +- [ ] Update `extractors/__init__.py` with exports +- [ ] Create `DynamicResponseInsight` in insights +- [ ] Register insight with @register_insight +- [ ] Create optimization template JSON +- [ ] Write `SYS_17_DYNAMIC_RESPONSE.md` protocol +- [ ] Update `SYS_12_EXTRACTOR_LIBRARY.md` +- [ ] End-to-end test: optimization with random vib objective + +### Phase 4+ (Future) +- [ ] Sine sweep processor +- [ ] Shock response spectrum +- [ ] Multi-axis SRSS/CQC +- [ ] Fatigue from random +- [ ] Neural surrogate for modal database + +--- + +## 6. Success Criteria + +### Technical +- [ ] Grms matches Miles' equation within 1% for SDOF +- [ ] Multi-mode response matches SOL 111 within 5% +- [ ] Processing time <5s per trial (excluding SOL 103) +- [ ] Memory <500MB for large models + +### Integration +- [ ] `extract_random_response()` works in `run_optimization.py` +- [ ] DynamicResponseInsight appears in dashboard +- [ ] Protocol SYS_17 is complete and accurate +- [ ] Extractor library updated with E23-E25 + +### User Experience +- [ ] User can optimize for Grms using natural language +- [ ] Dashboard shows response PSD and Grms history +- [ ] Error messages are clear and actionable + +--- + +## 7. Recommended Implementation Order + +1. **Day 1-2**: Phase 1.1-1.4 (data classes, transfer functions, PSD) +2. **Day 3**: Phase 1.5 (tests) + Phase 2.1 (random processor core) +3. **Day 4**: Phase 2.2 (tests) + Phase 3.1 (extractor) +4. **Day 5**: Phase 3.2-3.4 (registry, insight, docs) +5. **Day 6**: End-to-end testing, validation, refinement + +--- + +*This plan aligns the Master Plan with Atomizer's architecture for smooth implementation.* diff --git a/.claude/skills/modules/DYNAMIC_RESPONSE_MASTER_PLAN.md b/.claude/skills/modules/DYNAMIC_RESPONSE_MASTER_PLAN.md new file mode 100644 index 00000000..7f8df949 --- /dev/null +++ b/.claude/skills/modules/DYNAMIC_RESPONSE_MASTER_PLAN.md @@ -0,0 +1,1649 @@ +# Dynamic Response Processor - Master Implementation Plan + +## Atomizer Feature: Modal Superposition Engine for Dynamic Optimization + +**Document Version**: 1.0 +**Created**: 2025-12-22 +**Author**: Antoine + Claude +**Status**: Implementation Ready +**Priority**: High - Differentiating Feature + +--- + +## Executive Summary + +This document provides the complete implementation roadmap for adding **analytical dynamic response processing** to Atomizer. This feature enables optimization of structures under random vibration, sine sweep, and shock environments β€” **without running expensive frequency response FEA solutions for each design iteration**. + +### Value Proposition + +| Traditional Workflow | Atomizer Dynamic Workflow | +|---------------------|---------------------------| +| Run SOL 103 (modal) | Run SOL 103 (modal) | +| Run SOL 111 (frequency response) | **Skip** - compute analytically | +| Run SOL 112 (random) | **Skip** - compute analytically | +| ~15-30 min per iteration | ~30 sec per iteration | +| 100 iterations = 25-50 hours | 100 iterations = **50 minutes** | + +**Key Differentiator**: SATK is a post-processor. Atomizer becomes an **optimization engine with built-in dynamic response intelligence**. + +--- + +## Table of Contents + +1. [Technical Foundation](#1-technical-foundation) +2. [Architecture Design](#2-architecture-design) +3. [Implementation Phases](#3-implementation-phases) +4. [Module Specifications](#4-module-specifications) +5. [Integration Points](#5-integration-points) +6. [Validation Strategy](#6-validation-strategy) +7. [Performance Targets](#7-performance-targets) +8. [Risk Mitigation](#8-risk-mitigation) +9. [Future Extensions](#9-future-extensions) + +--- + +## 1. Technical Foundation + +### 1.1 The Physics: Why This Works + +The fundamental insight is that **dynamic response can be computed from modal data alone**. + +For a structure with N degrees of freedom, the equation of motion is: + +``` +[M]{ΓΌ} + [C]{uΜ‡} + [K]{u} = {F(t)} +``` + +Modal analysis (SOL 103) decomposes this into N independent single-DOF equations: + +``` +q̈ᡒ + 2ΞΆα΅’Ο‰α΅’qΜ‡α΅’ + Ο‰α΅’Β²qα΅’ = Lα΅’Β·a_base / mα΅’ +``` + +Where: +- `qα΅’` = modal coordinate (how much mode i is excited) +- `Ο‰α΅’` = natural frequency of mode i (rad/s) +- `ΞΆα΅’` = damping ratio of mode i +- `Lα΅’` = modal participation factor (how mode i couples to base motion) +- `mα΅’` = generalized (modal) mass + +**The key**: Once we extract (Ο‰α΅’, ΞΆα΅’, Lα΅’, Ο†α΅’) from SOL 103, we can compute the response to ANY excitation profile analytically β€” no more FEA needed. + +### 1.2 Transfer Function Mathematics + +#### Single Mode Transfer Function + +For base acceleration input, the displacement transfer function of mode i is: + +```python +def H_displacement(f, f_n, zeta): + """ + Displacement per unit base acceleration. + + H(f) = 1 / [(Ο‰β‚™Β² - ω²) + jΒ·2Β·ΞΆΒ·Ο‰β‚™Β·Ο‰] + + In dimensionless form with r = f/fβ‚™: + H(r) = 1 / [(1 - rΒ²) + jΒ·2Β·ΞΆΒ·r] + """ + r = f / f_n + return 1 / ((1 - r**2) + 2j * zeta * r) +``` + +#### Acceleration Transmissibility + +For base excitation, the ratio of response acceleration to input acceleration: + +```python +def transmissibility(f, f_n, zeta): + """ + T(f) = (1 + jΒ·2Β·ΞΆΒ·r) / [(1 - rΒ²) + jΒ·2Β·ΞΆΒ·r] + + |T| = 1 at low frequency (rigid body) + |T| = Q at resonance (amplification) + |T| β†’ 0 at high frequency (isolation) + """ + r = f / f_n + return (1 + 2j * zeta * r) / ((1 - r**2) + 2j * zeta * r) +``` + +#### Multi-Mode Combination + +Total response sums all modal contributions: + +```python +def total_response(frequencies, modes, participation_factors): + """ + H_total(f) = Ξ£α΅’ Lα΅’ Β· Ο†α΅’ Β· Hα΅’(f) / mα΅’ + + Where: + - Lα΅’ = participation factor + - Ο†α΅’ = mode shape at point of interest + - Hα΅’ = modal transfer function + - mα΅’ = generalized mass + """ + H_total = np.zeros(len(frequencies), dtype=complex) + for mode in modes: + H_mode = H_displacement(frequencies, mode.frequency, mode.damping) + H_total += mode.participation * mode.shape * H_mode / mode.gen_mass + return H_total +``` + +### 1.3 Random Vibration Response + +Random vibration is characterized by Power Spectral Density (PSD), typically in GΒ²/Hz. + +#### Response PSD + +```python +S_response(f) = |H(f)|Β² Γ— S_input(f) +``` + +#### RMS (Root Mean Square) + +```python +σ² = ∫ S_response(f) df # Mean square +RMS = βˆšΟƒΒ² # Root mean square +``` + +#### Peak Response + +For Gaussian random processes: +```python +Peak = k Γ— RMS +``` + +Where k (crest factor) depends on: +- **k = 3.0**: Standard 3-sigma (99.7% probability not exceeded) +- **k = 3.5-4.0**: Von Mises stress (non-Gaussian distribution) +- **k = 4.0-4.5**: Fatigue-critical applications + +#### Miles' Equation (SDOF Approximation) + +For quick estimates with white noise PSD: + +```python +Grms = √(Ο€/2 Γ— fβ‚™ Γ— Q Γ— PSD_level) +``` + +Where Q = 1/(2ΞΆ) is the quality factor. + +### 1.4 Stress Recovery from Modal Data + +Element stresses can also be computed via modal superposition: + +```python +Οƒ(t) = Ξ£α΅’ qα΅’(t) Γ— Οƒα΅’_modal +``` + +Where `Οƒα΅’_modal` is the stress pattern when mode i has unit amplitude. + +For random vibration, stress PSD: +```python +S_Οƒ(f) = |Ξ£α΅’ Lα΅’ Γ— Οƒα΅’_modal Γ— Hα΅’(f)|Β² Γ— S_input(f) +``` + +### 1.5 What We Extract from SOL 103 + +| Data | Source | Used For | +|------|--------|----------| +| Natural frequencies (fβ‚™) | OP2: eigenvalues | Transfer function poles | +| Mode shapes (Ο†) | OP2: eigenvectors | Response at specific nodes | +| Generalized mass (m) | OP2/F06: eigenvalues | Modal scaling | +| Modal effective mass | F06: MEFFMASS | Participation estimation | +| Participation factors (L) | F06: MEFFMASS | Base excitation coupling | +| Modal stress shapes | OP2: modal stress | Stress recovery | + +--- + +## 2. Architecture Design + +### 2.1 Module Structure + +``` +optimization_engine/ +β”œβ”€β”€ processors/ +β”‚ └── dynamic_response/ +β”‚ β”œβ”€β”€ __init__.py # Public API exports +β”‚ β”œβ”€β”€ modal_database.py # ModalDatabase, ModeShape classes +β”‚ β”œβ”€β”€ transfer_functions.py # FRF computation engine +β”‚ β”œβ”€β”€ random_vibration.py # Random response processor +β”‚ β”œβ”€β”€ sine_sweep.py # Harmonic response processor +β”‚ β”œβ”€β”€ shock_response.py # SRS computation (Phase 5) +β”‚ β”œβ”€β”€ stress_recovery.py # Modal stress combination +β”‚ β”œβ”€β”€ psd_profiles.py # Standard PSD definitions +β”‚ └── utils/ +β”‚ β”œβ”€β”€ __init__.py +β”‚ β”œβ”€β”€ frequency_utils.py # Frequency array generation +β”‚ β”œβ”€β”€ integration.py # Spectral integration methods +β”‚ └── statistics.py # Peak factors, distributions +β”‚ +β”œβ”€β”€ extractors/ +β”‚ β”œβ”€β”€ extract_modal_database.py # Full modal data extraction +β”‚ β”œβ”€β”€ extract_random_response.py # Random vibration objectives +β”‚ β”œβ”€β”€ extract_sine_response.py # Sine sweep objectives +β”‚ └── extract_dynamic_stress.py # Dynamic stress extraction +β”‚ +β”œβ”€β”€ hooks/ +β”‚ └── dynamic_response/ +β”‚ β”œβ”€β”€ pre_dynamic_analysis.py # Validate modal data +β”‚ └── post_dynamic_analysis.py # Cache results, logging +β”‚ +└── templates/ + β”œβ”€β”€ random_vibration_optimization.json + β”œβ”€β”€ sine_sweep_optimization.json + └── multi_environment_optimization.json +``` + +### 2.2 Class Hierarchy + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ DynamicResponseProcessor β”‚ +β”‚ (Abstract Base) β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ + modal_db: ModalDatabase β”‚ +β”‚ + frf_engine: TransferFunctionEngine β”‚ +β”‚ + compute_response() β†’ ResponseResult β”‚ +β”‚ + compute_at_node(node_id) β†’ float β”‚ +β”‚ + compute_stress(element_ids) β†’ Dict β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β–Ό β–Ό β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ RandomVibration β”‚ β”‚ SineSweep β”‚ β”‚ ShockResponse β”‚ +β”‚ Processor β”‚ β”‚ Processor β”‚ β”‚ Processor β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ + psd_profile β”‚ β”‚ + sweep_rate β”‚ β”‚ + srs_damping β”‚ +β”‚ + compute_grms()β”‚ β”‚ + dwell_freqs β”‚ β”‚ + compute_srs() β”‚ +β”‚ + compute_peak()β”‚ β”‚ + compute_peak()β”‚ β”‚ + compute_pseudoβ”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### 2.3 Data Flow + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ OPTIMIZATION LOOP β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ 1. UPDATE DESIGN PARAMETERS β”‚ +β”‚ NX Expression Manager β†’ thickness=2.5mm, rib_height=12mm β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ 2. RUN SOL 103 (Modal Analysis) β”‚ +β”‚ NX Solver β†’ model.op2, model.f06 β”‚ +β”‚ Time: ~20-60 seconds β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ 3. EXTRACT MODAL DATABASE β”‚ +β”‚ pyNastran β†’ ModalDatabase object β”‚ +β”‚ - Frequencies, mode shapes, participation factors β”‚ +β”‚ - Modal stress shapes (if needed) β”‚ +β”‚ Time: ~0.5-2 seconds β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ 4. COMPUTE DYNAMIC RESPONSE (Analytical) β”‚ +β”‚ RandomVibrationProcessor.compute_response() β”‚ +β”‚ - Build transfer functions: ~5ms β”‚ +β”‚ - Compute response PSD: ~10ms β”‚ +β”‚ - Integrate for RMS: ~5ms β”‚ +β”‚ - Stress recovery: ~50ms β”‚ +β”‚ Time: ~50-100 milliseconds β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ 5. RETURN OBJECTIVES & CONSTRAINTS β”‚ +β”‚ { β”‚ +β”‚ 'grms': 8.4, β”‚ +β”‚ 'peak_acceleration': 25.2, β”‚ +β”‚ 'peak_stress': 145.6, β”‚ +β”‚ 'first_frequency': 127.3 β”‚ +β”‚ } β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ 6. OPTUNA SUGGESTS NEXT DESIGN β”‚ +β”‚ TPE/CMA-ES/NSGA-II β†’ next trial parameters β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό + [REPEAT 1-6] +``` + +### 2.4 Memory Management + +For large models with many modes/elements, we need smart memory handling: + +```python +class ModalDatabase: + """ + Memory-efficient modal data storage. + + Strategies: + 1. Lazy loading - only load mode shapes when needed + 2. Sparse storage - only store non-zero eigenvector components + 3. Node filtering - only extract nodes of interest + 4. Disk caching - cache to HDF5 for reuse across trials + """ + + def __init__(self, op2_file, lazy_load=True): + self._op2_path = op2_file + self._modes_loaded = False + self._mode_shapes = None # Load on demand + + @property + def mode_shapes(self): + if not self._modes_loaded: + self._load_mode_shapes() + return self._mode_shapes + + def get_modes_for_nodes(self, node_ids: List[int]) -> np.ndarray: + """Extract mode shapes only for specified nodes.""" + # Efficient partial loading + pass + + def cache_to_hdf5(self, cache_path: Path): + """Cache modal database for fast reload.""" + pass + + @classmethod + def from_cache(cls, cache_path: Path) -> 'ModalDatabase': + """Load from HDF5 cache (10x faster than OP2).""" + pass +``` + +--- + +## 3. Implementation Phases + +### Phase 1: Core Infrastructure (Week 1) + +**Goal**: Build the foundational classes and basic functionality. + +#### Tasks + +| Task | Description | Effort | Deliverable | +|------|-------------|--------|-------------| +| 1.1 | Create `ModeShape` dataclass | 2h | `modal_database.py` | +| 1.2 | Create `ModalDatabase` class | 4h | `modal_database.py` | +| 1.3 | Implement OP2 extraction | 4h | `ModalDatabase.from_op2_f06()` | +| 1.4 | Implement F06 MEFFMASS parsing | 3h | Modal effective mass extraction | +| 1.5 | Create `TransferFunctionEngine` | 4h | `transfer_functions.py` | +| 1.6 | Single mode FRF | 2h | `single_mode_frf()` | +| 1.7 | Multi-mode combination | 2h | `base_excitation_frf()` | +| 1.8 | Unit tests for FRF | 3h | `tests/test_transfer_functions.py` | + +**Validation Checkpoint**: +- FRF matches closed-form SDOF solution +- Modal database correctly extracts from sample OP2 + +#### Code: ModeShape Dataclass + +```python +# optimization_engine/processors/dynamic_response/modal_database.py + +from dataclasses import dataclass, field +from typing import Dict, Optional, List +import numpy as np + +@dataclass +class ModeShape: + """ + Single mode shape data container. + + Stores all information needed for modal superposition + of one natural mode. + """ + # Identification + mode_number: int + + # Dynamic properties + frequency: float # Hz + damping_ratio: float = 0.02 # Dimensionless (2% default) + + # Modal mass/stiffness + generalized_mass: float = 1.0 # kg (normalized) + generalized_stiffness: float = 0.0 # N/m + + # Participation factors (for base excitation) + participation_x: float = 0.0 + participation_y: float = 0.0 + participation_z: float = 0.0 + + # Modal effective mass (absolute contribution) + effective_mass_x: float = 0.0 + effective_mass_y: float = 0.0 + effective_mass_z: float = 0.0 + + # Eigenvector at specific nodes + # Dict[node_id, np.array([tx, ty, tz, rx, ry, rz])] + eigenvector: Dict[int, np.ndarray] = field(default_factory=dict) + + # Modal stress shapes for stress recovery + # Dict[element_id, np.array([sxx, syy, szz, sxy, syz, sxz, vm])] + stress_shapes: Dict[int, np.ndarray] = field(default_factory=dict) + + # Derived properties + @property + def omega(self) -> float: + """Angular frequency (rad/s).""" + return 2 * np.pi * self.frequency + + @property + def omega_squared(self) -> float: + """ω² for efficiency.""" + return self.omega ** 2 + + @property + def quality_factor(self) -> float: + """Q = 1/(2ΞΆ) - amplification at resonance.""" + if self.damping_ratio > 0: + return 1 / (2 * self.damping_ratio) + return np.inf + + @property + def half_power_bandwidth(self) -> float: + """Ξ”f = fβ‚™/Q - bandwidth at -3dB.""" + return self.frequency / self.quality_factor + + def get_participation(self, direction: str) -> float: + """Get participation factor for direction ('x', 'y', 'z').""" + return getattr(self, f'participation_{direction}', 0.0) + + def get_effective_mass(self, direction: str) -> float: + """Get effective mass for direction.""" + return getattr(self, f'effective_mass_{direction}', 0.0) + + def get_eigenvector_component( + self, + node_id: int, + direction: str + ) -> float: + """Get eigenvector component at node in direction.""" + if node_id not in self.eigenvector: + return 0.0 + dir_map = {'x': 0, 'y': 1, 'z': 2, 'rx': 3, 'ry': 4, 'rz': 5} + idx = dir_map.get(direction, 0) + return self.eigenvector[node_id][idx] +``` + +### Phase 2: Random Vibration Processor (Week 2) + +**Goal**: Full random vibration response calculation with stress recovery. + +#### Tasks + +| Task | Description | Effort | Deliverable | +|------|-------------|--------|-------------| +| 2.1 | Create `PSDProfile` class | 2h | `psd_profiles.py` | +| 2.2 | Standard profiles (GEVS, MIL-STD) | 2h | `STANDARD_PROFILES` dict | +| 2.3 | PSD interpolation (log-log) | 2h | `PSDProfile.interpolate()` | +| 2.4 | Create `RandomVibrationProcessor` | 4h | `random_vibration.py` | +| 2.5 | Response PSD calculation | 3h | `compute_response_psd()` | +| 2.6 | Spectral integration | 2h | `compute_rms()` | +| 2.7 | Peak factor calculation | 2h | Gaussian + non-Gaussian | +| 2.8 | Modal contribution analysis | 2h | Identify dominant modes | +| 2.9 | Stress recovery | 4h | `compute_stress_response()` | +| 2.10 | Result dataclass | 2h | `RandomVibrationResult` | +| 2.11 | Integration tests | 4h | Miles' equation validation | + +**Validation Checkpoint**: +- Grms matches Miles' equation for SDOF +- Multi-mode response reasonable for sample model +- Stress recovery gives correct distribution + +#### Code: PSD Profiles + +```python +# optimization_engine/processors/dynamic_response/psd_profiles.py + +import numpy as np +from scipy import interpolate +from dataclasses import dataclass +from typing import List, Tuple, Optional +from pathlib import Path + + +@dataclass +class PSDProfile: + """ + Power Spectral Density profile for random vibration. + + Stores PSD as GΒ²/Hz vs frequency, with log-log interpolation. + """ + name: str + frequencies: np.ndarray # Hz + psd_values: np.ndarray # GΒ²/Hz + description: str = "" + source: str = "" # Standard reference + + @property + def freq_min(self) -> float: + return float(self.frequencies.min()) + + @property + def freq_max(self) -> float: + return float(self.frequencies.max()) + + @property + def grms(self) -> float: + """Overall Grms level.""" + from scipy.integrate import trapezoid + return np.sqrt(trapezoid(self.psd_values, self.frequencies)) + + def interpolate(self, frequencies: np.ndarray) -> np.ndarray: + """ + Interpolate PSD to arbitrary frequencies. + + Uses log-log interpolation (standard for PSD). + Clamps to edge values outside defined range. + """ + # Safety: ensure positive frequencies + frequencies = np.maximum(frequencies, 1e-6) + + log_f = np.log10(self.frequencies) + log_psd = np.log10(np.maximum(self.psd_values, 1e-20)) + + interp_func = interpolate.interp1d( + log_f, log_psd, + kind='linear', + bounds_error=False, + fill_value=(log_psd[0], log_psd[-1]) + ) + + return 10 ** interp_func(np.log10(frequencies)) + + def plot(self, ax=None, **kwargs): + """Plot PSD profile.""" + import matplotlib.pyplot as plt + if ax is None: + fig, ax = plt.subplots() + ax.loglog(self.frequencies, self.psd_values, **kwargs) + ax.set_xlabel('Frequency (Hz)') + ax.set_ylabel('PSD (GΒ²/Hz)') + ax.set_title(f'{self.name} - {self.grms:.2f} Grms') + ax.grid(True, which='both', alpha=0.3) + return ax + + @classmethod + def from_breakpoints( + cls, + breakpoints: List[Tuple[float, float]], + name: str = "Custom", + description: str = "" + ) -> 'PSDProfile': + """ + Create PSD from breakpoint specification. + + Args: + breakpoints: List of (frequency_hz, psd_g2_hz) tuples + name: Profile name + description: Description text + + Example: + psd = PSDProfile.from_breakpoints([ + (20, 0.01), # 20 Hz: 0.01 GΒ²/Hz + (80, 0.04), # 80 Hz: 0.04 GΒ²/Hz (ramp up) + (500, 0.04), # 500 Hz: 0.04 GΒ²/Hz (flat) + (2000, 0.007) # 2000 Hz: 0.007 GΒ²/Hz (ramp down) + ], name='MIL-STD-810') + """ + breakpoints = sorted(breakpoints, key=lambda x: x[0]) + frequencies = np.array([bp[0] for bp in breakpoints]) + psd_values = np.array([bp[1] for bp in breakpoints]) + return cls( + name=name, + frequencies=frequencies, + psd_values=psd_values, + description=description + ) + + @classmethod + def from_csv(cls, filepath: Path, name: str = None) -> 'PSDProfile': + """Load PSD from CSV file with freq,psd columns.""" + data = np.loadtxt(filepath, delimiter=',', skiprows=1) + return cls( + name=name or filepath.stem, + frequencies=data[:, 0], + psd_values=data[:, 1] + ) + + @classmethod + def flat( + cls, + level: float, + freq_min: float = 20, + freq_max: float = 2000, + name: str = "Flat" + ) -> 'PSDProfile': + """Create flat (white noise) PSD profile.""" + return cls( + name=name, + frequencies=np.array([freq_min, freq_max]), + psd_values=np.array([level, level]), + description=f"Flat PSD at {level} GΒ²/Hz" + ) + + +# ============================================================================ +# STANDARD PSD PROFILES +# ============================================================================ + +STANDARD_PROFILES = { + # NASA GEVS (General Environmental Verification Standard) + 'GEVS': PSDProfile.from_breakpoints([ + (20, 0.026), + (50, 0.16), + (800, 0.16), + (2000, 0.026), + ], name='NASA GEVS', + description='NASA General Environmental Verification Standard'), + + # MIL-STD-810G Category 1 (Basic) + 'MIL-STD-810G_CAT1': PSDProfile.from_breakpoints([ + (10, 0.04), + (40, 0.04), + (500, 0.04), + (2000, 0.01), + ], name='MIL-STD-810G Cat 1', + description='Basic transportation vibration'), + + # MIL-STD-810G Category 4 (Truck) + 'MIL-STD-810G_CAT4': PSDProfile.from_breakpoints([ + (5, 0.001), + (10, 0.015), + (40, 0.015), + (500, 0.015), + (2000, 0.003), + ], name='MIL-STD-810G Cat 4', + description='Truck transportation'), + + # MIL-STD-810G Category 7 (Jet Aircraft) + 'MIL-STD-810G_CAT7': PSDProfile.from_breakpoints([ + (15, 0.015), + (80, 0.015), + (350, 0.015), + (2000, 0.006), + ], name='MIL-STD-810G Cat 7', + description='Jet aircraft vibration'), + + # MIL-STD-810G Category 24 (Helicopter) + 'MIL-STD-810G_CAT24': PSDProfile.from_breakpoints([ + (5, 0.001), + (20, 0.01), + (80, 0.04), + (350, 0.04), + (2000, 0.007), + ], name='MIL-STD-810G Cat 24', + description='Helicopter vibration'), + + # NAVMAT P-9492 + 'NAVMAT': PSDProfile.from_breakpoints([ + (20, 0.04), + (80, 0.04), + (350, 0.04), + (2000, 0.007), + ], name='NAVMAT P-9492', + description='Navy shipboard equipment'), + + # ESA PSS-01-401 Qualification + 'ESA_QUAL': PSDProfile.from_breakpoints([ + (20, 0.01), + (100, 0.08), + (300, 0.08), + (2000, 0.008), + ], name='ESA PSS-01-401', + description='ESA qualification level'), + + # ESA PSS-01-401 Acceptance + 'ESA_ACCEPT': PSDProfile.from_breakpoints([ + (20, 0.005), + (100, 0.04), + (300, 0.04), + (2000, 0.004), + ], name='ESA PSS-01-401 Accept', + description='ESA acceptance level'), + + # SpaceX Falcon 9 (approximate public data) + 'FALCON9': PSDProfile.from_breakpoints([ + (20, 0.01), + (50, 0.08), + (800, 0.08), + (2000, 0.02), + ], name='Falcon 9 (approx)', + description='SpaceX Falcon 9 approximate envelope'), + + # Ariane 5 (approximate) + 'ARIANE5': PSDProfile.from_breakpoints([ + (20, 0.015), + (100, 0.09), + (400, 0.09), + (2000, 0.01), + ], name='Ariane 5 (approx)', + description='Ariane 5 approximate envelope'), +} + + +def get_psd_profile(name: str) -> PSDProfile: + """ + Get a standard PSD profile by name. + + Args: + name: Profile name (case-insensitive, underscores/hyphens flexible) + + Returns: + PSDProfile instance + + Raises: + ValueError: If profile not found + + Example: + >>> psd = get_psd_profile('GEVS') + >>> psd = get_psd_profile('mil-std-810g-cat7') + """ + # Normalize name + normalized = name.upper().replace('-', '_').replace(' ', '_') + + if normalized in STANDARD_PROFILES: + return STANDARD_PROFILES[normalized] + + # Try partial match + for key, profile in STANDARD_PROFILES.items(): + if normalized in key or key in normalized: + return profile + + available = ', '.join(STANDARD_PROFILES.keys()) + raise ValueError(f"Unknown PSD profile: '{name}'. Available: {available}") + + +def list_available_profiles() -> List[str]: + """Return list of available standard PSD profile names.""" + return list(STANDARD_PROFILES.keys()) +``` + +### Phase 3: Atomizer Integration (Week 3) + +**Goal**: Connect dynamic response processor to Atomizer's extractor and optimization systems. + +#### Tasks + +| Task | Description | Effort | Deliverable | +|------|-------------|--------|-------------| +| 3.1 | Create `extract_modal_database` | 3h | New extractor | +| 3.2 | Create `extract_random_response` | 3h | New extractor | +| 3.3 | Create `extract_dynamic_stress` | 3h | New extractor | +| 3.4 | Optimization template | 2h | `random_vibration_optimization.json` | +| 3.5 | Runner integration | 4h | Hook into `OptimizationRunner` | +| 3.6 | Dashboard widgets | 4h | Response PSD plot, Grms history | +| 3.7 | Result caching | 3h | Cache modal DB between similar designs | +| 3.8 | Documentation | 3h | User guide, API docs | +| 3.9 | End-to-end test | 4h | Full optimization run | + +**Validation Checkpoint**: +- Complete optimization runs successfully +- Dashboard shows dynamic response metrics +- Results match manual SATK/Nastran verification + +#### Code: Random Response Extractor + +```python +# optimization_engine/extractors/extract_random_response.py + +""" +Random Vibration Response Extractor for Atomizer Optimization +============================================================= + +Extracts random vibration metrics (Grms, peak acceleration, peak stress) +from SOL 103 modal analysis results using analytical modal superposition. + +This extractor enables dynamic optimization without running SOL 111. + +Example: + >>> result = extract_random_response( + ... op2_file='model.op2', + ... psd_profile='GEVS', + ... direction='z' + ... ) + >>> print(f"Grms: {result['grms']:.2f} G") + >>> print(f"Peak: {result['peak_acceleration']:.2f} G") +""" + +from pathlib import Path +from typing import Dict, Any, Optional, Union, List +import logging + +logger = logging.getLogger(__name__) + + +def extract_random_response( + op2_file: Union[str, Path], + psd_profile: str = 'GEVS', + direction: str = 'z', + damping: float = 0.02, + node_id: Optional[int] = None, + peak_sigma: float = 3.0, + f06_file: Optional[Union[str, Path]] = None, + n_modes: Optional[int] = None, + frequency_resolution: int = 500, +) -> Dict[str, Any]: + """ + Extract random vibration response for optimization objective/constraint. + + This is the main interface for dynamic optimization. It reads SOL 103 + results and computes random vibration response analytically. + + Args: + op2_file: Path to OP2 file from SOL 103 analysis + psd_profile: PSD specification - name ('GEVS', 'MIL-STD-810G_CAT7', etc.) + or path to CSV file + direction: Excitation direction ('x', 'y', 'z') + damping: Modal damping ratio (default 2%) + node_id: Specific node for response (None = use effective mass) + peak_sigma: Sigma level for peak (3.0 = 3-sigma) + f06_file: Optional F06 for modal effective mass table + n_modes: Number of modes to include (None = auto 95% mass) + frequency_resolution: Points in frequency array + + Returns: + Dict containing: + - 'grms': RMS acceleration (G) + - 'peak_acceleration': Peak acceleration (G) + - 'rms_displacement': RMS displacement (mm) + - 'peak_displacement': Peak displacement (mm) + - 'dominant_mode': Mode number with highest contribution + - 'dominant_frequency': Frequency of dominant mode (Hz) + - 'n_modes_used': Number of modes in calculation + - 'mass_fraction': Cumulative mass fraction of included modes + - 'psd_profile': Name of PSD profile used + - 'direction': Excitation direction + - 'success': True if extraction succeeded + - 'error': Error message if failed + + Example: + # For optimization objective + >>> result = extract_random_response('bracket.op2', psd_profile='GEVS') + >>> objective = result['peak_acceleration'] + + # For constraint checking + >>> result = extract_random_response('bracket.op2', psd_profile='GEVS') + >>> if result['peak_acceleration'] > 25.0: + ... print("Constraint violated!") + """ + from optimization_engine.processors.dynamic_response import ( + ModalDatabase, + RandomVibrationProcessor, + get_psd_profile + ) + + op2_path = Path(op2_file) + f06_path = Path(f06_file) if f06_file else None + + # Auto-detect F06 if not provided + if f06_path is None: + possible_f06 = op2_path.with_suffix('.f06') + if possible_f06.exists(): + f06_path = possible_f06 + logger.info(f"Auto-detected F06: {f06_path}") + + try: + # Build modal database + logger.info(f"Extracting modal database from {op2_path}") + modal_db = ModalDatabase.from_op2_f06( + op2_file=op2_path, + f06_file=f06_path, + damping=damping, + node_ids=[node_id] if node_id else None, + ) + + logger.info(f"Extracted {modal_db.n_modes} modes, " + f"freq range: {modal_db.frequency_range[0]:.1f}-" + f"{modal_db.frequency_range[1]:.1f} Hz") + + # Load PSD profile + if Path(psd_profile).exists(): + from optimization_engine.processors.dynamic_response import PSDProfile + psd = PSDProfile.from_csv(Path(psd_profile)) + else: + psd = get_psd_profile(psd_profile) + + logger.info(f"Using PSD profile: {psd.name} ({psd.grms:.2f} Grms)") + + # Create processor + processor = RandomVibrationProcessor( + modal_db=modal_db, + default_damping=damping + ) + + # Compute response + result = processor.compute_response( + psd_profile=psd, + direction=direction, + node_id=node_id, + n_modes=n_modes, + frequency_resolution=frequency_resolution, + peak_sigma=peak_sigma, + output_type='acceleration' + ) + + # Get mass fraction for included modes + n_modes_used = n_modes or modal_db.modes_for_mass_fraction(0.95, direction) + mass_fraction = modal_db.get_cumulative_mass_fraction(direction)[n_modes_used - 1] + + # Dominant mode info + dominant_freq = modal_db.modes[result.dominant_mode - 1].frequency + + return { + 'grms': result.rms_acceleration, + 'peak_acceleration': result.peak_acceleration, + 'rms_displacement': result.rms_displacement, + 'peak_displacement': result.peak_displacement, + 'dominant_mode': result.dominant_mode, + 'dominant_frequency': dominant_freq, + 'n_modes_used': n_modes_used, + 'mass_fraction': mass_fraction, + 'modal_contributions': result.modal_contributions, + 'psd_profile': psd.name, + 'psd_grms': psd.grms, + 'direction': direction, + 'peak_sigma': peak_sigma, + 'success': True, + 'error': None, + } + + except Exception as e: + logger.exception(f"Random response extraction failed: {e}") + return { + 'grms': None, + 'peak_acceleration': None, + 'rms_displacement': None, + 'peak_displacement': None, + 'dominant_mode': None, + 'dominant_frequency': None, + 'success': False, + 'error': str(e), + } + + +def extract_random_stress( + op2_file: Union[str, Path], + psd_profile: str = 'GEVS', + direction: str = 'z', + damping: float = 0.02, + peak_sigma: float = 3.0, + element_ids: Optional[List[int]] = None, + non_gaussian_correction: bool = True, + f06_file: Optional[Union[str, Path]] = None, +) -> Dict[str, Any]: + """ + Extract peak Von Mises stress from random vibration. + + Args: + op2_file: Path to OP2 with modal stress results + psd_profile: PSD specification + direction: Excitation direction + damping: Modal damping ratio + peak_sigma: Sigma level + element_ids: Specific elements (None = all) + non_gaussian_correction: Apply VM stress distribution correction + f06_file: Optional F06 for effective mass + + Returns: + Dict with: + - 'max_rms_von_mises': Maximum RMS VM stress (MPa) + - 'max_peak_von_mises': Maximum peak VM stress (MPa) + - 'critical_element': Element ID with max stress + - 'margin_of_safety': (allowable - peak) / peak + - 'n_elements': Number of elements analyzed + """ + from optimization_engine.processors.dynamic_response import ( + ModalDatabase, + RandomVibrationProcessor, + get_psd_profile + ) + + op2_path = Path(op2_file) + f06_path = Path(f06_file) if f06_file else None + + try: + # Build modal database with stress shapes + modal_db = ModalDatabase.from_op2_f06( + op2_file=op2_path, + f06_file=f06_path, + damping=damping, + element_ids=element_ids, # Request stress shapes + ) + + # Load PSD + psd = get_psd_profile(psd_profile) if not Path(psd_profile).exists() \ + else PSDProfile.from_csv(Path(psd_profile)) + + # Process + processor = RandomVibrationProcessor(modal_db, damping) + result = processor.compute_stress_response( + psd_profile=psd, + direction=direction, + element_ids=element_ids, + peak_sigma=peak_sigma, + non_gaussian_correction=non_gaussian_correction, + ) + + return { + 'max_rms_von_mises': result['max_rms_von_mises'], + 'max_peak_von_mises': result['max_peak_von_mises'], + 'critical_element': result['critical_element'], + 'n_elements': result['n_elements'], + 'success': True, + 'error': None, + } + + except Exception as e: + logger.exception(f"Stress extraction failed: {e}") + return { + 'max_rms_von_mises': None, + 'max_peak_von_mises': None, + 'critical_element': None, + 'success': False, + 'error': str(e), + } + + +# Convenience functions for optimization objectives + +def get_grms(op2_file, psd='GEVS', direction='z', damping=0.02) -> float: + """Quick Grms extraction for objective function.""" + result = extract_random_response(op2_file, psd, direction, damping) + return result['grms'] if result['success'] else float('inf') + + +def get_peak_g(op2_file, psd='GEVS', direction='z', damping=0.02, sigma=3.0) -> float: + """Quick peak acceleration extraction for constraint.""" + result = extract_random_response(op2_file, psd, direction, damping, peak_sigma=sigma) + return result['peak_acceleration'] if result['success'] else float('inf') + + +def get_peak_stress(op2_file, psd='GEVS', direction='z', damping=0.02, sigma=3.0) -> float: + """Quick peak stress extraction for constraint.""" + result = extract_random_stress(op2_file, psd, direction, damping, sigma) + return result['max_peak_von_mises'] if result['success'] else float('inf') +``` + +### Phase 4: Sine Sweep Processor (Week 4) + +**Goal**: Add harmonic/sine sweep response capability. + +#### Tasks + +| Task | Description | Effort | Deliverable | +|------|-------------|--------|-------------| +| 4.1 | Sine sweep transfer function | 3h | Magnitude/phase at frequencies | +| 4.2 | Resonance search | 2h | Find peak frequencies | +| 4.3 | Dwell analysis | 3h | Response at specific frequencies | +| 4.4 | Sweep rate effects | 2h | Transient buildup correction | +| 4.5 | Create `SineSweepProcessor` | 4h | `sine_sweep.py` | +| 4.6 | Create extractor | 2h | `extract_sine_response.py` | +| 4.7 | Optimization template | 2h | `sine_sweep_optimization.json` | +| 4.8 | Tests | 3h | Validate against analytical | + +### Phase 5: Advanced Features (Future) + +**Goal**: Extended capabilities for complete dynamic qualification. + +#### Shock Response Spectrum (SRS) +- Compute SRS from transient pulse or base motion +- Pseudo-velocity and pseudo-acceleration +- Compare against equipment SRS limits + +#### Multi-Axis Excitation +- SRSS (Square Root Sum of Squares) combination +- CQC (Complete Quadratic Combination) for correlated inputs +- Full 6-DOF base motion + +#### Fatigue from Random Vibration +- Narrow-band fatigue using Miner's rule +- Dirlik method for wide-band random +- Cycle counting from PSD + +#### Neural Surrogate for Modal Database +- Train GNN to predict (frequencies, mode shapes) from geometry +- Skip SOL 103 entirely for similar designs +- 1000x additional speedup potential + +--- + +## 4. Module Specifications + +### 4.1 ModalDatabase + +```python +class ModalDatabase: + """ + Central storage for all modal analysis data. + + Responsibilities: + - Extract data from OP2/F06 files + - Store mode shapes, frequencies, participation factors + - Provide efficient access patterns for dynamic calculations + - Support caching and serialization + + Thread Safety: Read-only after construction + Memory: O(n_modes Γ— n_nodes Γ— 6) for full eigenvectors + """ + + # Class attributes + SUPPORTED_ELEMENTS = ['CQUAD4', 'CTRIA3', 'CHEXA', 'CPENTA', 'CTETRA'] + + # Required data + model_name: str + total_mass: float + center_of_gravity: np.ndarray + modes: List[ModeShape] + + # Optional data + node_coordinates: Dict[int, np.ndarray] + element_connectivity: Dict[int, List[int]] + + # Metadata + source_op2: Path + source_f06: Path + extraction_timestamp: datetime + nastran_version: str + + # Methods + @classmethod + def from_op2_f06(cls, op2_file, f06_file, **options) -> 'ModalDatabase' + + def get_mode(self, mode_number: int) -> ModeShape + def get_frequencies(self) -> np.ndarray + def get_participation_factors(self, direction: str) -> np.ndarray + def get_cumulative_mass_fraction(self, direction: str) -> np.ndarray + def modes_for_mass_fraction(self, fraction: float, direction: str) -> int + + def to_hdf5(self, filepath: Path) + @classmethod + def from_hdf5(cls, filepath: Path) -> 'ModalDatabase' + + def to_dict(self) -> Dict + def summary(self) -> str +``` + +### 4.2 TransferFunctionEngine + +```python +class TransferFunctionEngine: + """ + Computes frequency response functions from modal data. + + Responsibilities: + - Single mode FRF calculation + - Multi-mode combination + - Base excitation transmissibility + - Point excitation mobility (future) + + Performance: Vectorized numpy operations + Accuracy: Double precision complex arithmetic + """ + + def __init__(self, modal_db: ModalDatabase) + + # Core FRF methods + def single_mode_frf( + self, + frequencies: np.ndarray, + mode: ModeShape, + output_type: str = 'displacement' + ) -> np.ndarray # Complex FRF + + def base_excitation_frf( + self, + frequencies: np.ndarray, + direction: str, + output_type: str = 'acceleration', + node_id: int = None, + n_modes: int = None + ) -> np.ndarray # Complex transmissibility + + def point_excitation_frf( + self, + frequencies: np.ndarray, + input_node: int, + input_direction: str, + output_node: int, + output_direction: str + ) -> np.ndarray # Complex FRF + + # Stress FRF + def stress_frf( + self, + frequencies: np.ndarray, + element_id: int, + direction: str + ) -> np.ndarray # Complex stress transfer function +``` + +### 4.3 RandomVibrationProcessor + +```python +class RandomVibrationProcessor: + """ + Computes random vibration response using modal superposition. + + This is the SATK-equivalent processor. + + Capabilities: + - Grms and peak response calculation + - Response PSD computation + - Modal contribution analysis + - Stress recovery with non-Gaussian correction + - Multiple response locations + + Performance Target: <100ms for typical aerospace model + """ + + def __init__(self, modal_db: ModalDatabase, default_damping: float = 0.02) + + # Main computation methods + def compute_response( + self, + psd_profile: PSDProfile, + direction: str = 'z', + node_id: int = None, + n_modes: int = None, + frequency_resolution: int = 500, + peak_sigma: float = 3.0, + output_type: str = 'acceleration' + ) -> RandomVibrationResult + + def compute_stress_response( + self, + psd_profile: PSDProfile, + direction: str = 'z', + element_ids: List[int] = None, + peak_sigma: float = 3.0, + non_gaussian_correction: bool = True + ) -> Dict[str, Any] + + # Quick objective/constraint methods + def compute_grms(self, psd_profile, direction, node_id=None) -> float + def compute_peak_g(self, psd_profile, direction, sigma=3.0) -> float + def compute_peak_stress(self, psd_profile, direction, sigma=3.0) -> float + + # Analysis methods + def get_modal_contributions(self, psd_profile, direction) -> Dict[int, float] + def get_response_psd(self, psd_profile, direction) -> Tuple[np.ndarray, np.ndarray] + def identify_critical_modes(self, psd_profile, direction, threshold=0.1) -> List[int] +``` + +--- + +## 5. Integration Points + +### 5.1 Extractor Registry + +Add to `optimization_engine/extractors/__init__.py`: + +```python +# Dynamic response extractors +from .extract_modal_database import extract_modal_database +from .extract_random_response import ( + extract_random_response, + extract_random_stress, + get_grms, + get_peak_g, + get_peak_stress +) +from .extract_sine_response import extract_sine_response + +__all__ = [ + # ... existing extractors ... + + # Dynamic response + 'extract_modal_database', + 'extract_random_response', + 'extract_random_stress', + 'get_grms', + 'get_peak_g', + 'get_peak_stress', + 'extract_sine_response', +] +``` + +### 5.2 Feature Registry + +Add to `optimization_engine/feature_registry.json`: + +```json +{ + "dynamic_response": { + "random_vibration_processor": { + "feature_id": "random_vibration_processor", + "name": "Random Vibration Processor", + "description": "Modal superposition-based random vibration response calculation", + "category": "dynamics", + "implementation": { + "file_path": "optimization_engine/processors/dynamic_response/random_vibration.py", + "class_name": "RandomVibrationProcessor" + }, + "interface": { + "inputs": [ + {"name": "modal_database", "type": "ModalDatabase"}, + {"name": "psd_profile", "type": "PSDProfile"}, + {"name": "direction", "type": "str"} + ], + "outputs": [ + {"name": "grms", "type": "float"}, + {"name": "peak_acceleration", "type": "float"}, + {"name": "response_psd", "type": "np.ndarray"} + ] + } + } + } +} +``` + +### 5.3 Dashboard Integration + +New dashboard components for dynamic response: + +```typescript +// atomizer-dashboard/src/components/DynamicResponse/ +β”œβ”€β”€ ResponsePSDPlot.tsx // Log-log PSD plot with input/output +β”œβ”€β”€ TransmissibilityPlot.tsx // FRF magnitude vs frequency +β”œβ”€β”€ ModalContributionBar.tsx // Bar chart of mode contributions +β”œβ”€β”€ GrmsHistoryChart.tsx // Grms vs trial number +β”œβ”€β”€ CampbellDiagram.tsx // For rotating machinery (future) +└── DynamicResponseCard.tsx // Summary card for dashboard +``` + +### 5.4 CLI Integration + +```bash +# New CLI commands +atomizer dynamic extract model.op2 --psd GEVS --direction z +atomizer dynamic psd-list +atomizer dynamic psd-plot GEVS --output psd.png +atomizer optimize --template random_vibration_optimization.json +``` + +--- + +## 6. Validation Strategy + +### 6.1 Unit Tests + +```python +# tests/test_dynamic_response/ + +test_modal_database.py +β”œβ”€β”€ test_mode_shape_creation +β”œβ”€β”€ test_op2_extraction +β”œβ”€β”€ test_f06_parsing +β”œβ”€β”€ test_mass_fraction_calculation +└── test_hdf5_caching + +test_transfer_functions.py +β”œβ”€β”€ test_sdof_frf_analytical +β”œβ”€β”€ test_resonance_amplification +β”œβ”€β”€ test_phase_at_resonance +β”œβ”€β”€ test_multi_mode_combination +└── test_base_excitation_transmissibility + +test_random_vibration.py +β”œβ”€β”€ test_miles_equation_sdof +β”œβ”€β”€ test_grms_calculation +β”œβ”€β”€ test_peak_factor +β”œβ”€β”€ test_non_gaussian_correction +β”œβ”€β”€ test_modal_contributions +└── test_stress_recovery + +test_psd_profiles.py +β”œβ”€β”€ test_interpolation +β”œβ”€β”€ test_grms_integration +β”œβ”€β”€ test_standard_profiles +└── test_csv_loading +``` + +### 6.2 Integration Tests + +```python +# tests/integration/ + +test_dynamic_optimization.py +β”œβ”€β”€ test_full_random_vib_optimization +β”œβ”€β”€ test_multi_objective_dynamic +β”œβ”€β”€ test_constraint_handling +└── test_dashboard_updates + +test_nastran_comparison.py +β”œβ”€β”€ test_vs_sol111_response +β”œβ”€β”€ test_vs_sol112_random +└── test_stress_recovery_accuracy +``` + +### 6.3 Validation Cases + +| Case | Model | Validation Against | Acceptance | +|------|-------|-------------------|------------| +| SDOF | Single mass-spring | Miles' equation | <1% error | +| Cantilever beam | Simple beam | SOL 111 FRF | <5% error | +| Plate | Rectangular plate | SOL 112 random | <5% error | +| Bracket | Real component | SATK results | <10% error | + +### 6.4 Benchmark Against SATK + +If possible, run same model through both Atomizer and SATK: + +``` +Model: CubeSat bracket +PSD: GEVS +Direction: Z-axis +Damping: 2% + +Compare: +- Grms +- Peak acceleration +- Dominant mode +- Response PSD shape +- Peak stress location +``` + +--- + +## 7. Performance Targets + +### 7.1 Timing Budgets + +| Operation | Target | Notes | +|-----------|--------|-------| +| OP2 read (50 modes) | <2s | pyNastran | +| F06 parse (MEFFMASS) | <0.5s | Regex parsing | +| Modal DB construction | <1s | Data structuring | +| FRF calculation (1000 freq) | <20ms | Vectorized | +| Response PSD | <20ms | Element-wise | +| Spectral integration | <10ms | Trapezoidal | +| Stress recovery (1000 elements) | <200ms | Parallel possible | +| **Total per trial** | **<3s** | Excluding SOL 103 | + +### 7.2 Memory Budgets + +| Data | Size Estimate | Notes | +|------|---------------|-------| +| Modal DB (100 modes, 10k nodes) | ~50 MB | Full eigenvectors | +| Modal DB (100 modes, 100 nodes) | ~5 MB | Sparse storage | +| Response PSD (1000 freq) | <100 KB | Just arrays | +| Stress shapes (100 modes, 1k elements) | ~50 MB | If needed | + +### 7.3 Scalability + +Tested configurations: + +| Model Size | Modes | Nodes | Elements | Expected Time | +|------------|-------|-------|----------|---------------| +| Small | 50 | 5,000 | 10,000 | <2s | +| Medium | 100 | 50,000 | 100,000 | <5s | +| Large | 200 | 200,000 | 500,000 | <15s | +| Very Large | 500 | 1,000,000 | 2,000,000 | <60s | + +--- + +## 8. Risk Mitigation + +### 8.1 Technical Risks + +| Risk | Probability | Impact | Mitigation | +|------|-------------|--------|------------| +| pyNastran can't read NX OP2 | Low | High | Test early, have F06 fallback | +| Modal stress not in OP2 | Medium | Medium | Request STRESS(MODAL) output | +| Poor accuracy vs SOL 111 | Low | High | Validate thoroughly, document assumptions | +| Memory issues large models | Medium | Medium | Lazy loading, sparse storage | +| Complex modes (damped) | Low | Low | Detect and warn, use magnitude | + +### 8.2 Mitigation Actions + +1. **Early OP2 Testing**: Test pyNastran with NX 2412 OP2 files in week 1 +2. **F06 Fallback**: Implement F06 parsing for critical data +3. **Validation Suite**: Build comprehensive test cases against SOL 111 +4. **Memory Profiling**: Profile with large models, implement streaming if needed +5. **Documentation**: Clear documentation of assumptions and limitations + +--- + +## 9. Future Extensions + +### 9.1 Short-Term (3-6 months) + +- Sine sweep processor +- Shock response spectrum +- Multi-axis excitation +- Response at multiple nodes +- Automatic mode selection + +### 9.2 Medium-Term (6-12 months) + +- Fatigue from random vibration +- Acoustic loading (diffuse field) +- Coupled structural-acoustic +- Time-domain transient from PSD +- Design sensitivity (βˆ‚response/βˆ‚parameter) + +### 9.3 Long-Term (12+ months) + +- Neural surrogate for modal database +- Real-time response visualization +- Uncertainty quantification +- Multi-physics coupling (thermal + dynamic) +- Foundation model for structural dynamics + +--- + +## 10. Success Metrics + +### 10.1 Technical Metrics + +- [ ] Grms accuracy <5% vs SOL 111 +- [ ] Peak stress accuracy <10% vs SOL 112 +- [ ] Processing time <5s per trial +- [ ] Memory <500MB for large models +- [ ] 100% test coverage for core modules + +### 10.2 User Metrics + +- [ ] Complete optimization in <1 hour (100 trials) +- [ ] Dashboard shows real-time dynamic metrics +- [ ] Documentation enables self-service usage +- [ ] Template covers 80% of use cases + +### 10.3 Business Metrics + +- [ ] Feature demo ready for marketing video +- [ ] 3+ real-world optimization case studies +- [ ] Positive feedback from beta users +- [ ] Competitive differentiation documented + +--- + +## Appendix A: Reference Equations + +### A.1 Modal Superposition + +``` +Physical response: +u(x,t) = Ξ£α΅’ Ο†α΅’(x) Β· qα΅’(t) + +Modal equation of motion: +q̈ᡒ + 2ΞΆα΅’Ο‰α΅’qΜ‡α΅’ + Ο‰α΅’Β²qα΅’ = Lα΅’Β·a_base(t) / mα΅’ + +Frequency response function: +Hα΅’(Ο‰) = 1 / (Ο‰α΅’Β² - ω² + jΒ·2ΞΆα΅’Ο‰α΅’Ο‰) +``` + +### A.2 Random Vibration + +``` +Response PSD: +Sα΅§(f) = |H(f)|Β² Β· Sβ‚“(f) + +Mean square: +σ² = βˆ«β‚€^∞ Sα΅§(f) df + +Peak (Gaussian): +peak = k Β· Οƒ, k β‰ˆ 3 for 3-sigma +``` + +### A.3 Miles' Equation + +``` +For SDOF with white noise input: +Grms = √(Ο€/2 Β· fβ‚™ Β· Q Β· PSD) + +Where: +- fβ‚™ = natural frequency (Hz) +- Q = 1/(2ΞΆ) = quality factor +- PSD = input PSD level (GΒ²/Hz) +``` + +--- + +## Appendix B: File Formats + +### B.1 OP2 Data Blocks + +| Table | Contains | Used For | +|-------|----------|----------| +| OUGV1 | Eigenvectors | Mode shapes | +| LAMA | Eigenvalues | Frequencies | +| OES1 | Element stress | Modal stress shapes | +| OGPFB1 | Grid point forces | Participation | +| OQMG1 | Modal participation | Effective mass | + +### B.2 F06 Tables + +| Section | Contains | Parsing Pattern | +|---------|----------|-----------------| +| REAL EIGENVALUES | Frequencies, gen mass | Regex table | +| MODAL EFFECTIVE MASS | Participation | Regex table | +| MODAL PARTICIPATION FACTORS | L factors | Regex table | + +### B.3 PSD CSV Format + +```csv +frequency_hz,psd_g2_hz +20,0.026 +50,0.16 +800,0.16 +2000,0.026 +``` + +--- + +*Document End - Dynamic Response Processor Master Plan* diff --git a/.claude/skills/modules/OPTIMIZATION_ENGINE_MIGRATION_PLAN.md b/.claude/skills/modules/OPTIMIZATION_ENGINE_MIGRATION_PLAN.md new file mode 100644 index 00000000..7a095c08 --- /dev/null +++ b/.claude/skills/modules/OPTIMIZATION_ENGINE_MIGRATION_PLAN.md @@ -0,0 +1,932 @@ +# Optimization Engine Reorganization - Migration Plan + +## Comprehensive Guide for Safe Codebase Restructuring + +**Document Version**: 1.0 +**Created**: 2025-12-23 +**Status**: PLANNING - Do Not Execute Without Review +**Risk Level**: HIGH - Affects 557+ locations across 276 files + +--- + +## Executive Summary + +This document provides a complete migration plan for reorganizing `optimization_engine/` from 50+ loose files to a clean modular structure. This is a **high-impact refactoring** that requires careful execution. + +### Impact Summary + +| Category | Files Affected | Lines to Change | +|----------|----------------|-----------------| +| Python imports (internal) | 90+ files | ~145 changes | +| Python imports (studies) | 30+ folders | ~153 changes | +| Python imports (tests) | 30+ files | ~79 changes | +| Python imports (dashboard) | 3 files | ~11 changes | +| Documentation (protocols, skills) | 119 files | ~200 changes | +| JSON configs | 4 files | ~50 changes | +| **TOTAL** | **276 files** | **~640 changes** | + +--- + +## Part 1: Current State Analysis + +### 1.1 Top-Level Files Requiring Migration + +These 50+ files at `optimization_engine/` root need to move: + +``` +SURROGATES (6 files) β†’ processors/surrogates/ +β”œβ”€β”€ neural_surrogate.py +β”œβ”€β”€ generic_surrogate.py +β”œβ”€β”€ adaptive_surrogate.py +β”œβ”€β”€ simple_mlp_surrogate.py +β”œβ”€β”€ active_learning_surrogate.py +└── surrogate_tuner.py + +OPTIMIZATION CORE (7 files) β†’ core/ +β”œβ”€β”€ runner.py +β”œβ”€β”€ runner_with_neural.py +β”œβ”€β”€ base_runner.py +β”œβ”€β”€ intelligent_optimizer.py +β”œβ”€β”€ method_selector.py +β”œβ”€β”€ strategy_selector.py +└── strategy_portfolio.py + +NX INTEGRATION (6 files) β†’ nx/ +β”œβ”€β”€ nx_solver.py +β”œβ”€β”€ nx_updater.py +β”œβ”€β”€ nx_session_manager.py +β”œβ”€β”€ solve_simulation.py +β”œβ”€β”€ solve_simulation_simple.py +└── model_cleanup.py + +STUDY MANAGEMENT (5 files) β†’ study/ +β”œβ”€β”€ study_creator.py +β”œβ”€β”€ study_wizard.py +β”œβ”€β”€ study_state.py +β”œβ”€β”€ study_reset.py +└── study_continuation.py + +REPORTING (5 files) β†’ reporting/ +β”œβ”€β”€ generate_report.py +β”œβ”€β”€ generate_report_markdown.py +β”œβ”€β”€ comprehensive_results_analyzer.py +β”œβ”€β”€ visualizer.py +└── landscape_analyzer.py + +CONFIG (4 files) β†’ config/ +β”œβ”€β”€ config_manager.py +β”œβ”€β”€ optimization_config_builder.py +β”œβ”€β”€ optimization_setup_wizard.py +└── capability_matcher.py + +AGENTS/RESEARCH (5 files) β†’ agents/ or future/ +β”œβ”€β”€ research_agent.py +β”œβ”€β”€ pynastran_research_agent.py +β”œβ”€β”€ targeted_research_planner.py +β”œβ”€β”€ workflow_decomposer.py +└── step_classifier.py + +MISC (remaining ~15 files) - evaluate individually +β”œβ”€β”€ logger.py β†’ utils/ +β”œβ”€β”€ op2_extractor.py β†’ extractors/ +β”œβ”€β”€ extractor_library.py β†’ extractors/ +β”œβ”€β”€ export_expressions.py β†’ nx/ +β”œβ”€β”€ import_expressions.py β†’ nx/ +β”œβ”€β”€ mesh_converter.py β†’ nx/ +β”œβ”€β”€ simulation_validator.py β†’ validators/ +β”œβ”€β”€ auto_doc.py β†’ utils/ +β”œβ”€β”€ auto_trainer.py β†’ processors/surrogates/ +β”œβ”€β”€ realtime_tracking.py β†’ utils/ +β”œβ”€β”€ benchmarking_substudy.py β†’ study/ +β”œβ”€β”€ codebase_analyzer.py β†’ utils/ +β”œβ”€β”€ training_data_exporter.py β†’ processors/surrogates/ +β”œβ”€β”€ pruning_logger.py β†’ utils/ +β”œβ”€β”€ adaptive_characterization.py β†’ processors/ +└── generate_history_from_trials.py β†’ study/ +``` + +### 1.2 Well-Organized Directories (Keep As-Is) + +These are already properly organized: + +``` +extractors/ βœ“ 20+ extractors, clean __init__.py +insights/ βœ“ 8 insight types, registry pattern +hooks/ βœ“ nx_cad/, nx_cae/ subdirs +gnn/ βœ“ Neural surrogate for Zernike +templates/ βœ“ Config templates +schemas/ βœ“ JSON schemas +validators/ βœ“ Validation code +plugins/ βœ“ Hook manager system +utils/ βœ“ Utility functions +custom_functions/ βœ“ NX material generator +model_discovery/ βœ“ Model introspection +future/ βœ“ Experimental code +``` + +--- + +## Part 2: Target Structure + +### 2.1 Proposed Final Structure + +``` +optimization_engine/ +β”‚ +β”œβ”€β”€ __init__.py # Updated with backwards-compat aliases +β”‚ +β”œβ”€β”€ core/ # NEW - Optimization engine core +β”‚ β”œβ”€β”€ __init__.py +β”‚ β”œβ”€β”€ runner.py +β”‚ β”œβ”€β”€ base_runner.py +β”‚ β”œβ”€β”€ runner_with_neural.py +β”‚ β”œβ”€β”€ intelligent_optimizer.py +β”‚ β”œβ”€β”€ method_selector.py +β”‚ β”œβ”€β”€ strategy_selector.py +β”‚ └── strategy_portfolio.py +β”‚ +β”œβ”€β”€ processors/ # NEW - Data processing & algorithms +β”‚ β”œβ”€β”€ __init__.py +β”‚ β”œβ”€β”€ surrogates/ +β”‚ β”‚ β”œβ”€β”€ __init__.py +β”‚ β”‚ β”œβ”€β”€ neural_surrogate.py +β”‚ β”‚ β”œβ”€β”€ generic_surrogate.py +β”‚ β”‚ β”œβ”€β”€ adaptive_surrogate.py +β”‚ β”‚ β”œβ”€β”€ simple_mlp_surrogate.py +β”‚ β”‚ β”œβ”€β”€ active_learning_surrogate.py +β”‚ β”‚ β”œβ”€β”€ surrogate_tuner.py +β”‚ β”‚ β”œβ”€β”€ auto_trainer.py +β”‚ β”‚ └── training_data_exporter.py +β”‚ β”‚ +β”‚ └── dynamic_response/ # NEW - From master plan +β”‚ β”œβ”€β”€ __init__.py +β”‚ β”œβ”€β”€ modal_database.py +β”‚ β”œβ”€β”€ transfer_functions.py +β”‚ β”œβ”€β”€ random_vibration.py +β”‚ β”œβ”€β”€ psd_profiles.py +β”‚ └── utils/ +β”‚ +β”œβ”€β”€ nx/ # NEW - NX/Nastran integration +β”‚ β”œβ”€β”€ __init__.py +β”‚ β”œβ”€β”€ solver.py # Was nx_solver.py +β”‚ β”œβ”€β”€ updater.py # Was nx_updater.py +β”‚ β”œβ”€β”€ session_manager.py # Was nx_session_manager.py +β”‚ β”œβ”€β”€ solve_simulation.py +β”‚ β”œβ”€β”€ solve_simulation_simple.py +β”‚ β”œβ”€β”€ model_cleanup.py +β”‚ β”œβ”€β”€ export_expressions.py +β”‚ β”œβ”€β”€ import_expressions.py +β”‚ └── mesh_converter.py +β”‚ +β”œβ”€β”€ study/ # NEW - Study management +β”‚ β”œβ”€β”€ __init__.py +β”‚ β”œβ”€β”€ creator.py # Was study_creator.py +β”‚ β”œβ”€β”€ wizard.py # Was study_wizard.py +β”‚ β”œβ”€β”€ state.py # Was study_state.py +β”‚ β”œβ”€β”€ reset.py # Was study_reset.py +β”‚ β”œβ”€β”€ continuation.py # Was study_continuation.py +β”‚ β”œβ”€β”€ benchmarking.py # Was benchmarking_substudy.py +β”‚ └── history_generator.py # Was generate_history_from_trials.py +β”‚ +β”œβ”€β”€ reporting/ # NEW - Reports and analysis +β”‚ β”œβ”€β”€ __init__.py +β”‚ β”œβ”€β”€ report_generator.py +β”‚ β”œβ”€β”€ markdown_report.py +β”‚ β”œβ”€β”€ results_analyzer.py +β”‚ β”œβ”€β”€ visualizer.py +β”‚ └── landscape_analyzer.py +β”‚ +β”œβ”€β”€ config/ # NEW - Configuration +β”‚ β”œβ”€β”€ __init__.py +β”‚ β”œβ”€β”€ manager.py +β”‚ β”œβ”€β”€ builder.py +β”‚ β”œβ”€β”€ setup_wizard.py +β”‚ └── capability_matcher.py +β”‚ +β”œβ”€β”€ extractors/ # EXISTING - Add op2_extractor, extractor_library +β”œβ”€β”€ insights/ # EXISTING +β”œβ”€β”€ hooks/ # EXISTING +β”œβ”€β”€ gnn/ # EXISTING +β”œβ”€β”€ templates/ # EXISTING +β”œβ”€β”€ schemas/ # EXISTING +β”œβ”€β”€ validators/ # EXISTING - Add simulation_validator +β”œβ”€β”€ plugins/ # EXISTING +β”œβ”€β”€ utils/ # EXISTING - Add logger, auto_doc, etc. +β”œβ”€β”€ custom_functions/ # EXISTING +β”œβ”€β”€ model_discovery/ # EXISTING +└── future/ # EXISTING - Move agents here + β”œβ”€β”€ research_agent.py + β”œβ”€β”€ pynastran_research_agent.py + β”œβ”€β”€ targeted_research_planner.py + β”œβ”€β”€ workflow_decomposer.py + └── step_classifier.py +``` + +--- + +## Part 3: Import Mapping Tables + +### 3.1 Old β†’ New Import Mapping + +This is the critical reference for all updates: + +```python +# CORE OPTIMIZATION +"from optimization_engine.runner" β†’ "from optimization_engine.core.runner" +"from optimization_engine.base_runner" β†’ "from optimization_engine.core.base_runner" +"from optimization_engine.runner_with_neural" β†’ "from optimization_engine.core.runner_with_neural" +"from optimization_engine.intelligent_optimizer" β†’ "from optimization_engine.core.intelligent_optimizer" +"from optimization_engine.method_selector" β†’ "from optimization_engine.core.method_selector" +"from optimization_engine.strategy_selector" β†’ "from optimization_engine.core.strategy_selector" +"from optimization_engine.strategy_portfolio" β†’ "from optimization_engine.core.strategy_portfolio" + +# SURROGATES +"from optimization_engine.neural_surrogate" β†’ "from optimization_engine.processors.surrogates.neural_surrogate" +"from optimization_engine.generic_surrogate" β†’ "from optimization_engine.processors.surrogates.generic_surrogate" +"from optimization_engine.adaptive_surrogate" β†’ "from optimization_engine.processors.surrogates.adaptive_surrogate" +"from optimization_engine.simple_mlp_surrogate" β†’ "from optimization_engine.processors.surrogates.simple_mlp_surrogate" +"from optimization_engine.active_learning_surrogate" β†’ "from optimization_engine.processors.surrogates.active_learning_surrogate" +"from optimization_engine.surrogate_tuner" β†’ "from optimization_engine.processors.surrogates.surrogate_tuner" + +# NX INTEGRATION +"from optimization_engine.nx_solver" β†’ "from optimization_engine.nx.solver" +"from optimization_engine.nx_updater" β†’ "from optimization_engine.nx.updater" +"from optimization_engine.nx_session_manager" β†’ "from optimization_engine.nx.session_manager" +"from optimization_engine.solve_simulation" β†’ "from optimization_engine.nx.solve_simulation" +"from optimization_engine.model_cleanup" β†’ "from optimization_engine.nx.model_cleanup" +"from optimization_engine.export_expressions" β†’ "from optimization_engine.nx.export_expressions" +"from optimization_engine.import_expressions" β†’ "from optimization_engine.nx.import_expressions" +"from optimization_engine.mesh_converter" β†’ "from optimization_engine.nx.mesh_converter" + +# STUDY MANAGEMENT +"from optimization_engine.study_creator" β†’ "from optimization_engine.study.creator" +"from optimization_engine.study_wizard" β†’ "from optimization_engine.study.wizard" +"from optimization_engine.study_state" β†’ "from optimization_engine.study.state" +"from optimization_engine.study_reset" β†’ "from optimization_engine.study.reset" +"from optimization_engine.study_continuation" β†’ "from optimization_engine.study.continuation" + +# REPORTING +"from optimization_engine.generate_report" β†’ "from optimization_engine.reporting.report_generator" +"from optimization_engine.generate_report_markdown" β†’ "from optimization_engine.reporting.markdown_report" +"from optimization_engine.comprehensive_results" β†’ "from optimization_engine.reporting.results_analyzer" +"from optimization_engine.visualizer" β†’ "from optimization_engine.reporting.visualizer" +"from optimization_engine.landscape_analyzer" β†’ "from optimization_engine.reporting.landscape_analyzer" + +# CONFIG +"from optimization_engine.config_manager" β†’ "from optimization_engine.config.manager" +"from optimization_engine.optimization_config_builder" β†’ "from optimization_engine.config.builder" +"from optimization_engine.optimization_setup_wizard" β†’ "from optimization_engine.config.setup_wizard" +"from optimization_engine.capability_matcher" β†’ "from optimization_engine.config.capability_matcher" + +# UTILITIES (moving to utils/) +"from optimization_engine.logger" β†’ "from optimization_engine.utils.logger" +"from optimization_engine.auto_doc" β†’ "from optimization_engine.utils.auto_doc" +"from optimization_engine.realtime_tracking" β†’ "from optimization_engine.utils.realtime_tracking" +"from optimization_engine.codebase_analyzer" β†’ "from optimization_engine.utils.codebase_analyzer" +"from optimization_engine.pruning_logger" β†’ "from optimization_engine.utils.pruning_logger" + +# RESEARCH/AGENTS (moving to future/) +"from optimization_engine.research_agent" β†’ "from optimization_engine.future.research_agent" +"from optimization_engine.workflow_decomposer" β†’ "from optimization_engine.future.workflow_decomposer" +"from optimization_engine.step_classifier" β†’ "from optimization_engine.future.step_classifier" +``` + +### 3.2 Backwards Compatibility Aliases + +Add to `optimization_engine/__init__.py` for transition period: + +```python +# BACKWARDS COMPATIBILITY ALIASES +# These allow old imports to work during migration period +# Remove after all code is updated + +# Core +from optimization_engine.core.runner import * +from optimization_engine.core.base_runner import * +from optimization_engine.core.intelligent_optimizer import * + +# NX +from optimization_engine.nx import solver as nx_solver +from optimization_engine.nx import updater as nx_updater +from optimization_engine.nx import session_manager as nx_session_manager +from optimization_engine.nx import solve_simulation + +# Study +from optimization_engine.study import creator as study_creator +from optimization_engine.study import wizard as study_wizard +from optimization_engine.study import state as study_state + +# Surrogates +from optimization_engine.processors.surrogates import neural_surrogate +from optimization_engine.processors.surrogates import generic_surrogate + +# Config +from optimization_engine.config import manager as config_manager + +# Utils +from optimization_engine.utils import logger + +# Deprecation warnings (optional) +import warnings +def __getattr__(name): + deprecated = { + 'nx_solver': 'optimization_engine.nx.solver', + 'study_creator': 'optimization_engine.study.creator', + # ... more mappings + } + if name in deprecated: + warnings.warn( + f"Importing {name} from optimization_engine is deprecated. " + f"Use {deprecated[name]} instead.", + DeprecationWarning, + stacklevel=2 + ) + # Return the module anyway for compatibility + raise AttributeError(f"module 'optimization_engine' has no attribute '{name}'") +``` + +--- + +## Part 4: Files Requiring Updates + +### 4.1 Internal optimization_engine Files (90+ files) + +**Highest-impact internal files:** + +| File | Imports to Update | Priority | +|------|-------------------|----------| +| `runner.py` | nx_solver, config_manager, extractors | Critical | +| `base_runner.py` | config_manager, validators | Critical | +| `intelligent_optimizer.py` | runner, neural_surrogate, method_selector | Critical | +| `runner_with_neural.py` | runner, neural_surrogate | High | +| `gnn/gnn_optimizer.py` | polar_graph, zernike_gnn, nx_solver | High | +| `hooks/nx_cad/*.py` | nx_session_manager | High | +| `plugins/hook_manager.py` | validators, config | Medium | + +### 4.2 Study Scripts (30+ folders, 153 imports) + +**Pattern in every study's `run_optimization.py`:** + +```python +# BEFORE +from optimization_engine.nx_solver import run_nx_simulation +from optimization_engine.extractors import ZernikeExtractor +from optimization_engine.gnn.gnn_optimizer import ZernikeGNNOptimizer + +# AFTER +from optimization_engine.nx.solver import run_nx_simulation +from optimization_engine.extractors import ZernikeExtractor # unchanged +from optimization_engine.gnn.gnn_optimizer import ZernikeGNNOptimizer # unchanged +``` + +**Studies requiring updates:** + +``` +studies/ +β”œβ”€β”€ M1_Mirror/ +β”‚ β”œβ”€β”€ m1_mirror_adaptive_V*/run_optimization.py (12 files) +β”‚ └── [other variants] +β”œβ”€β”€ Simple_Bracket/ +β”‚ β”œβ”€β”€ bracket_*/run_optimization.py (8 files) +β”œβ”€β”€ UAV_Arm/ +β”‚ β”œβ”€β”€ */run_optimization.py (4 files) +β”œβ”€β”€ Drone_Gimbal/ +β”œβ”€β”€ Simple_Beam/ +└── [others] +``` + +### 4.3 Test Files (30+ files, 79 imports) + +Located in `tests/`: + +``` +tests/ +β”œβ”€β”€ test_extractors.py +β”œβ”€β”€ test_zernike_*.py (5+ files) +β”œβ”€β”€ test_neural_surrogate.py +β”œβ”€β”€ test_gnn_*.py +β”œβ”€β”€ test_nx_solver.py +β”œβ”€β”€ test_study_*.py +└── [others] +``` + +### 4.4 Dashboard Backend (3 files, 11 imports) + +``` +atomizer-dashboard/backend/api/ +β”œβ”€β”€ main.py # sys.path setup +β”œβ”€β”€ routes/optimization.py # study management imports +└── routes/insights.py # insight imports +``` + +### 4.5 Documentation Files (119 files) + +**Protocols requiring updates:** + +| Protocol | References | Changes Needed | +|----------|------------|----------------| +| SYS_10_IMSO.md | intelligent_optimizer | core.intelligent_optimizer | +| SYS_12_EXTRACTOR_LIBRARY.md | extractors/* | None (unchanged) | +| SYS_14_NEURAL_ACCELERATION.md | neural_surrogate, gnn/* | processors.surrogates.* | +| SYS_15_METHOD_SELECTOR.md | method_selector | core.method_selector | +| OP_01_CREATE_STUDY.md | study_creator, study_wizard | study.creator, study.wizard | +| OP_02_RUN_OPTIMIZATION.md | runner, nx_solver | core.runner, nx.solver | + +**Skill files requiring updates:** + +| Skill File | Changes | +|------------|---------| +| 01_CHEATSHEET.md | Path references | +| core/study-creation-core.md | Import examples | +| modules/extractors-catalog.md | Directory paths | +| modules/neural-acceleration.md | Surrogate imports | + +### 4.6 JSON Configuration Files (4 files) + +**feature_registry.json** (878 lines, 50+ path references): + +```json +// BEFORE +"file_path": "optimization_engine/neural_surrogate.py" + +// AFTER +"file_path": "optimization_engine/processors/surrogates/neural_surrogate.py" +``` + +--- + +## Part 5: Migration Execution Plan + +### Phase 0: Pre-Migration (30 min) + +1. **Create full backup** + ```bash + git stash # Save any uncommitted changes + git checkout -b refactor/optimization-engine-reorganization + cp -r optimization_engine optimization_engine_backup + ``` + +2. **Run baseline tests** + ```bash + python -m pytest tests/ -v --tb=short > baseline_tests.log 2>&1 + ``` + +3. **Document current working state** + - Run one study end-to-end + - Verify dashboard loads + - Note any existing failures + +### Phase 1: Create Directory Structure (15 min) + +```bash +# Create new directories +mkdir -p optimization_engine/core +mkdir -p optimization_engine/processors/surrogates +mkdir -p optimization_engine/processors/dynamic_response +mkdir -p optimization_engine/nx +mkdir -p optimization_engine/study +mkdir -p optimization_engine/reporting +mkdir -p optimization_engine/config + +# Create __init__.py files +touch optimization_engine/core/__init__.py +touch optimization_engine/processors/__init__.py +touch optimization_engine/processors/surrogates/__init__.py +touch optimization_engine/processors/dynamic_response/__init__.py +touch optimization_engine/nx/__init__.py +touch optimization_engine/study/__init__.py +touch optimization_engine/reporting/__init__.py +touch optimization_engine/config/__init__.py +``` + +### Phase 2: Move Files (30 min) + +**Execute in this order to minimize circular import issues:** + +```bash +# 1. UTILITIES FIRST (no dependencies) +mv optimization_engine/logger.py optimization_engine/utils/ +mv optimization_engine/auto_doc.py optimization_engine/utils/ +mv optimization_engine/realtime_tracking.py optimization_engine/utils/ +mv optimization_engine/codebase_analyzer.py optimization_engine/utils/ +mv optimization_engine/pruning_logger.py optimization_engine/utils/ + +# 2. CONFIG (low dependencies) +mv optimization_engine/config_manager.py optimization_engine/config/manager.py +mv optimization_engine/optimization_config_builder.py optimization_engine/config/builder.py +mv optimization_engine/optimization_setup_wizard.py optimization_engine/config/setup_wizard.py +mv optimization_engine/capability_matcher.py optimization_engine/config/capability_matcher.py + +# 3. NX INTEGRATION +mv optimization_engine/nx_solver.py optimization_engine/nx/solver.py +mv optimization_engine/nx_updater.py optimization_engine/nx/updater.py +mv optimization_engine/nx_session_manager.py optimization_engine/nx/session_manager.py +mv optimization_engine/solve_simulation.py optimization_engine/nx/ +mv optimization_engine/solve_simulation_simple.py optimization_engine/nx/ +mv optimization_engine/model_cleanup.py optimization_engine/nx/ +mv optimization_engine/export_expressions.py optimization_engine/nx/ +mv optimization_engine/import_expressions.py optimization_engine/nx/ +mv optimization_engine/mesh_converter.py optimization_engine/nx/ + +# 4. SURROGATES +mv optimization_engine/neural_surrogate.py optimization_engine/processors/surrogates/ +mv optimization_engine/generic_surrogate.py optimization_engine/processors/surrogates/ +mv optimization_engine/adaptive_surrogate.py optimization_engine/processors/surrogates/ +mv optimization_engine/simple_mlp_surrogate.py optimization_engine/processors/surrogates/ +mv optimization_engine/active_learning_surrogate.py optimization_engine/processors/surrogates/ +mv optimization_engine/surrogate_tuner.py optimization_engine/processors/surrogates/ +mv optimization_engine/auto_trainer.py optimization_engine/processors/surrogates/ +mv optimization_engine/training_data_exporter.py optimization_engine/processors/surrogates/ + +# 5. STUDY MANAGEMENT +mv optimization_engine/study_creator.py optimization_engine/study/creator.py +mv optimization_engine/study_wizard.py optimization_engine/study/wizard.py +mv optimization_engine/study_state.py optimization_engine/study/state.py +mv optimization_engine/study_reset.py optimization_engine/study/reset.py +mv optimization_engine/study_continuation.py optimization_engine/study/continuation.py +mv optimization_engine/benchmarking_substudy.py optimization_engine/study/benchmarking.py +mv optimization_engine/generate_history_from_trials.py optimization_engine/study/history_generator.py + +# 6. REPORTING +mv optimization_engine/generate_report.py optimization_engine/reporting/report_generator.py +mv optimization_engine/generate_report_markdown.py optimization_engine/reporting/markdown_report.py +mv optimization_engine/comprehensive_results_analyzer.py optimization_engine/reporting/results_analyzer.py +mv optimization_engine/visualizer.py optimization_engine/reporting/ +mv optimization_engine/landscape_analyzer.py optimization_engine/reporting/ + +# 7. CORE (depends on many things, do last) +mv optimization_engine/runner.py optimization_engine/core/ +mv optimization_engine/base_runner.py optimization_engine/core/ +mv optimization_engine/runner_with_neural.py optimization_engine/core/ +mv optimization_engine/intelligent_optimizer.py optimization_engine/core/ +mv optimization_engine/method_selector.py optimization_engine/core/ +mv optimization_engine/strategy_selector.py optimization_engine/core/ +mv optimization_engine/strategy_portfolio.py optimization_engine/core/ + +# 8. RESEARCH/FUTURE +mv optimization_engine/research_agent.py optimization_engine/future/ +mv optimization_engine/pynastran_research_agent.py optimization_engine/future/ +mv optimization_engine/targeted_research_planner.py optimization_engine/future/ +mv optimization_engine/workflow_decomposer.py optimization_engine/future/ +mv optimization_engine/step_classifier.py optimization_engine/future/ + +# 9. REMAINING MISC +mv optimization_engine/op2_extractor.py optimization_engine/extractors/ +mv optimization_engine/extractor_library.py optimization_engine/extractors/ +mv optimization_engine/simulation_validator.py optimization_engine/validators/ +mv optimization_engine/adaptive_characterization.py optimization_engine/processors/ +``` + +### Phase 3: Update Internal Imports (1-2 hours) + +**Use sed/grep for bulk updates:** + +```bash +# Example sed commands (run from project root) + +# NX imports +find . -name "*.py" -exec sed -i 's/from optimization_engine\.nx_solver/from optimization_engine.nx.solver/g' {} + +find . -name "*.py" -exec sed -i 's/from optimization_engine\.nx_updater/from optimization_engine.nx.updater/g' {} + +find . -name "*.py" -exec sed -i 's/from optimization_engine\.nx_session_manager/from optimization_engine.nx.session_manager/g' {} + +find . -name "*.py" -exec sed -i 's/from optimization_engine\.solve_simulation/from optimization_engine.nx.solve_simulation/g' {} + + +# Study imports +find . -name "*.py" -exec sed -i 's/from optimization_engine\.study_creator/from optimization_engine.study.creator/g' {} + +find . -name "*.py" -exec sed -i 's/from optimization_engine\.study_wizard/from optimization_engine.study.wizard/g' {} + +find . -name "*.py" -exec sed -i 's/from optimization_engine\.study_state/from optimization_engine.study.state/g' {} + + +# Config imports +find . -name "*.py" -exec sed -i 's/from optimization_engine\.config_manager/from optimization_engine.config.manager/g' {} + + +# Core imports +find . -name "*.py" -exec sed -i 's/from optimization_engine\.runner import/from optimization_engine.core.runner import/g' {} + +find . -name "*.py" -exec sed -i 's/from optimization_engine\.base_runner/from optimization_engine.core.base_runner/g' {} + +find . -name "*.py" -exec sed -i 's/from optimization_engine\.intelligent_optimizer/from optimization_engine.core.intelligent_optimizer/g' {} + + +# Surrogate imports +find . -name "*.py" -exec sed -i 's/from optimization_engine\.neural_surrogate/from optimization_engine.processors.surrogates.neural_surrogate/g' {} + +find . -name "*.py" -exec sed -i 's/from optimization_engine\.generic_surrogate/from optimization_engine.processors.surrogates.generic_surrogate/g' {} + + +# Logger +find . -name "*.py" -exec sed -i 's/from optimization_engine\.logger/from optimization_engine.utils.logger/g' {} + +``` + +**Handle edge cases manually:** +- `import optimization_engine.nx_solver` (without `from`) +- Dynamic imports using `importlib` +- String references in configs + +### Phase 4: Create __init__.py Files (30 min) + +**Example: `optimization_engine/core/__init__.py`** + +```python +""" +Optimization Engine Core + +Main optimization runners and algorithm selection. +""" + +from .runner import OptimizationRunner +from .base_runner import BaseRunner +from .intelligent_optimizer import IntelligentOptimizer, IMSO +from .method_selector import MethodSelector +from .strategy_selector import StrategySelector +from .strategy_portfolio import StrategyPortfolio + +__all__ = [ + 'OptimizationRunner', + 'BaseRunner', + 'IntelligentOptimizer', + 'IMSO', + 'MethodSelector', + 'StrategySelector', + 'StrategyPortfolio', +] +``` + +**Create similar for each new directory.** + +### Phase 5: Add Backwards Compatibility (30 min) + +Update `optimization_engine/__init__.py`: + +```python +""" +Optimization Engine for Atomizer + +Reorganized structure (v2.0): +- core/ - Optimization runners +- processors/ - Data processing (surrogates, dynamic response) +- nx/ - NX/Nastran integration +- study/ - Study management +- reporting/ - Reports and analysis +- config/ - Configuration +- extractors/ - Physics extraction +- insights/ - Visualizations +- gnn/ - Graph neural networks +- hooks/ - NX hooks +""" + +# Re-export commonly used items at top level for convenience +from optimization_engine.core.runner import OptimizationRunner +from optimization_engine.core.intelligent_optimizer import IMSO +from optimization_engine.nx.solver import run_nx_simulation, NXSolver +from optimization_engine.study.creator import create_study +from optimization_engine.config.manager import ConfigManager + +# Backwards compatibility aliases (deprecated) +# These will be removed in a future version +import warnings as _warnings + +def _deprecated_import(old_name, new_location): + _warnings.warn( + f"Importing '{old_name}' directly from optimization_engine is deprecated. " + f"Use '{new_location}' instead.", + DeprecationWarning, + stacklevel=3 + ) + +# Lazy loading for backwards compatibility +def __getattr__(name): + # Map old names to new locations + _compat_map = { + 'nx_solver': ('optimization_engine.nx.solver', 'nx_solver'), + 'nx_updater': ('optimization_engine.nx.updater', 'nx_updater'), + 'study_creator': ('optimization_engine.study.creator', 'study_creator'), + 'config_manager': ('optimization_engine.config.manager', 'config_manager'), + 'runner': ('optimization_engine.core.runner', 'runner'), + 'neural_surrogate': ('optimization_engine.processors.surrogates.neural_surrogate', 'neural_surrogate'), + } + + if name in _compat_map: + module_path, attr = _compat_map[name] + _deprecated_import(name, module_path) + import importlib + module = importlib.import_module(module_path) + return module + + raise AttributeError(f"module 'optimization_engine' has no attribute '{name}'") +``` + +### Phase 6: Update Documentation (1 hour) + +**Use sed for bulk updates in markdown:** + +```bash +# Update protocol files +find docs/protocols -name "*.md" -exec sed -i 's/optimization_engine\/nx_solver/optimization_engine\/nx\/solver/g' {} + +find docs/protocols -name "*.md" -exec sed -i 's/optimization_engine\/study_creator/optimization_engine\/study\/creator/g' {} + +# ... etc for all mappings + +# Update .claude skills +find .claude -name "*.md" -exec sed -i 's/optimization_engine\.nx_solver/optimization_engine.nx.solver/g' {} + +# ... etc +``` + +**Manual review needed for:** +- Code examples in markdown +- Directory structure diagrams +- Command-line examples + +### Phase 7: Update JSON Configs (30 min) + +**feature_registry.json** - Use a Python script: + +```python +import json + +with open('optimization_engine/feature_registry.json', 'r') as f: + registry = json.load(f) + +# Define path mappings +path_map = { + 'optimization_engine/neural_surrogate.py': 'optimization_engine/processors/surrogates/neural_surrogate.py', + 'optimization_engine/nx_solver.py': 'optimization_engine/nx/solver.py', + # ... all other mappings +} + +def update_paths(obj): + if isinstance(obj, dict): + for key, value in obj.items(): + if key == 'file_path' and value in path_map: + obj[key] = path_map[value] + else: + update_paths(value) + elif isinstance(obj, list): + for item in obj: + update_paths(item) + +update_paths(registry) + +with open('optimization_engine/feature_registry.json', 'w') as f: + json.dump(registry, f, indent=2) +``` + +### Phase 8: Testing & Validation (1-2 hours) + +```bash +# 1. Run Python import tests +python -c "from optimization_engine.core.runner import OptimizationRunner" +python -c "from optimization_engine.nx.solver import run_nx_simulation" +python -c "from optimization_engine.study.creator import create_study" +python -c "from optimization_engine.processors.surrogates.neural_surrogate import NeuralSurrogate" + +# 2. Run backwards compatibility tests +python -c "from optimization_engine import nx_solver" # Should work with deprecation warning + +# 3. Run test suite +python -m pytest tests/ -v --tb=short + +# 4. Test a study end-to-end +cd studies/Simple_Bracket/bracket_displacement_maximizing +python run_optimization.py --trials 2 --dry-run + +# 5. Test dashboard +cd atomizer-dashboard +python backend/api/main.py # Should start without import errors +``` + +### Phase 9: Cleanup (15 min) + +```bash +# Remove backup after successful testing +rm -rf optimization_engine_backup + +# Remove any .pyc files that might cache old imports +find . -name "*.pyc" -delete +find . -name "__pycache__" -type d -exec rm -rf {} + + +# Commit +git add -A +git commit -m "refactor: Reorganize optimization_engine into modular structure + +- Move 50+ top-level files into logical subdirectories +- Create core/, processors/, nx/, study/, reporting/, config/ +- Add backwards compatibility aliases with deprecation warnings +- Update all imports across codebase (640+ changes) +- Update documentation and protocols + +BREAKING: Direct imports from optimization_engine.* are deprecated. +Use new paths like optimization_engine.core.runner instead. + +πŸ€– Generated with [Claude Code](https://claude.com/claude-code)" +``` + +--- + +## Part 6: Rollback Plan + +If migration fails: + +```bash +# Option 1: Git reset (if not committed) +git checkout -- . + +# Option 2: Restore backup +rm -rf optimization_engine +mv optimization_engine_backup optimization_engine + +# Option 3: Git revert (if committed) +git revert HEAD +``` + +--- + +## Part 7: Post-Migration Tasks + +### 7.1 Update LAC Knowledge Base + +Record the migration in LAC: + +```python +from knowledge_base.lac import get_lac +lac = get_lac() +lac.record_insight( + category="architecture", + context="optimization_engine reorganization", + insight="Migrated 50+ files into modular structure. New paths: " + "core/, processors/, nx/, study/, reporting/, config/. " + "Backwards compat aliases provided with deprecation warnings.", + confidence=1.0, + tags=["refactoring", "architecture", "breaking-change"] +) +``` + +### 7.2 Update CLAUDE.md + +Add section about new structure. + +### 7.3 Schedule Deprecation Removal + +After 2-4 weeks of stable operation: +- Remove backwards compatibility aliases +- Update remaining old imports +- Clean up __init__.py files + +--- + +## Part 8: Time Estimate Summary + +| Phase | Task | Time | +|-------|------|------| +| 0 | Pre-migration (backup, baseline) | 30 min | +| 1 | Create directory structure | 15 min | +| 2 | Move files | 30 min | +| 3 | Update internal imports | 1-2 hours | +| 4 | Create __init__.py files | 30 min | +| 5 | Add backwards compatibility | 30 min | +| 6 | Update documentation | 1 hour | +| 7 | Update JSON configs | 30 min | +| 8 | Testing & validation | 1-2 hours | +| 9 | Cleanup & commit | 15 min | +| **TOTAL** | | **6-8 hours** | + +--- + +## Part 9: Decision Points + +### Option A: Full Migration Now +- **Pros**: Clean structure, enables dynamic_response cleanly +- **Cons**: 6-8 hours work, risk of breakage +- **When**: When you have a dedicated half-day + +### Option B: Minimal Migration (processors/ only) +- **Pros**: Low risk, enables dynamic_response +- **Cons**: Leaves technical debt, inconsistent structure +- **When**: If you need dynamic_response urgently + +### Option C: Defer Migration +- **Pros**: Zero risk now +- **Cons**: Dynamic_response goes in awkward location +- **When**: If stability is critical + +--- + +## Appendix A: Automated Migration Script + +A Python script to automate most of the migration: + +```python +#!/usr/bin/env python +""" +optimization_engine Migration Script + +Run with: python migrate_optimization_engine.py --dry-run +Then: python migrate_optimization_engine.py --execute +""" + +import os +import re +import shutil +from pathlib import Path + +# ... full script would be ~200 lines +# Handles: directory creation, file moves, import updates, __init__ generation +``` + +Would you like me to create this full automation script? + +--- + +*This plan ensures a safe, reversible migration with clear validation steps.* diff --git a/CLAUDE.md b/CLAUDE.md index e95fc9c5..fd75ce82 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,12 +108,23 @@ The Protocol Operating System (POS) provides layered documentation: **CRITICAL: Always use the `atomizer` conda environment.** +### Paths (DO NOT SEARCH - use these directly) +``` +Python: C:\Users\antoi\anaconda3\envs\atomizer\python.exe +Conda: C:\Users\antoi\anaconda3\Scripts\conda.exe +``` + +### Running Python Scripts ```bash -conda activate atomizer -python run_optimization.py +# Option 1: PowerShell with conda activate (RECOMMENDED) +powershell -Command "conda activate atomizer; python your_script.py" + +# Option 2: Direct path (no activation needed) +C:\Users\antoi\anaconda3\envs\atomizer\python.exe your_script.py ``` **DO NOT:** +- Search for Python paths (`where python`, etc.) - they're documented above - Install packages with pip/conda (everything is installed) - Create new virtual environments - Use system Python diff --git a/atomizer-dashboard/frontend/src/pages/Setup.tsx b/atomizer-dashboard/frontend/src/pages/Setup.tsx index 7ebfe68f..47f25e5f 100644 --- a/atomizer-dashboard/frontend/src/pages/Setup.tsx +++ b/atomizer-dashboard/frontend/src/pages/Setup.tsx @@ -155,9 +155,9 @@ export default function Setup() { })), algorithm: { name: rawConfig.optimizer?.name || rawConfig.algorithm?.name || 'Optuna', - sampler: rawConfig.optimization_settings?.sampler || rawConfig.algorithm?.sampler || 'TPESampler', + sampler: rawConfig.optimization?.algorithm || rawConfig.optimization_settings?.sampler || rawConfig.algorithm?.sampler || 'TPESampler', pruner: rawConfig.optimization_settings?.pruner || rawConfig.algorithm?.pruner, - n_trials: rawConfig.optimization_settings?.n_trials || rawConfig.trials?.n_trials || selectedStudy.progress.total, + n_trials: rawConfig.optimization?.n_trials || rawConfig.optimization_settings?.n_trials || rawConfig.trials?.n_trials || selectedStudy.progress.total, timeout: rawConfig.optimization_settings?.timeout }, fea_model: rawConfig.fea_model || rawConfig.solver ? { diff --git a/atomizer-dashboard/restart-dev.bat b/atomizer-dashboard/restart-dev.bat new file mode 100644 index 00000000..c4cf748a --- /dev/null +++ b/atomizer-dashboard/restart-dev.bat @@ -0,0 +1,35 @@ +@echo off +REM Atomizer Dashboard - Development Restart Script +REM Kills existing processes and restarts both backend and frontend + +echo ======================================== +echo Atomizer Dashboard - Restart +echo ======================================== + +REM Kill existing processes on ports 8000 and 5173 +echo Stopping existing processes... +for /f "tokens=5" %%a in ('netstat -ano ^| findstr :8000 ^| findstr LISTENING') do ( + taskkill /F /PID %%a 2>nul +) +for /f "tokens=5" %%a in ('netstat -ano ^| findstr :5173 ^| findstr LISTENING') do ( + taskkill /F /PID %%a 2>nul +) + +timeout /t 2 /nobreak >nul + +REM Start backend in new window +echo Starting backend... +start "Atomizer Backend" cmd /k "cd /d %~dp0backend && conda activate atomizer && uvicorn api.main:app --reload --host 0.0.0.0 --port 8000" + +timeout /t 3 /nobreak >nul + +REM Start frontend in new window +echo Starting frontend... +start "Atomizer Frontend" cmd /k "cd /d %~dp0frontend && npm run dev" + +echo ======================================== +echo Dashboard restarted! +echo Backend: http://localhost:8000 +echo Frontend: http://localhost:5173 +echo ======================================== +pause diff --git a/docs/06_PHYSICS/00_INDEX.md b/docs/06_PHYSICS/00_INDEX.md new file mode 100644 index 00000000..d9f1422d --- /dev/null +++ b/docs/06_PHYSICS/00_INDEX.md @@ -0,0 +1,59 @@ +# Physics Documentation Index + +This folder contains detailed physics and domain-specific documentation for Atomizer's analysis capabilities. These documents explain the **engineering and scientific foundations** behind the extractors and insights. + +--- + +## Document Catalog + +| Document | Topic | When to Read | +|----------|-------|--------------| +| [ZERNIKE_FUNDAMENTALS.md](ZERNIKE_FUNDAMENTALS.md) | Zernike polynomial basics, RMS calculation, multi-subcase analysis | Setting up mirror optimization, understanding WFE metrics | +| [ZERNIKE_OPD_METHOD.md](ZERNIKE_OPD_METHOD.md) | **Rigorous OPD method** for lateral displacement correction | Lateral support optimization, validating WFE accuracy | + +--- + +## Quick Navigation + +### For Mirror/Optics Optimization + +1. **New to Zernike?** Start with [ZERNIKE_FUNDAMENTALS.md](ZERNIKE_FUNDAMENTALS.md) +2. **Lateral support optimization?** Read [ZERNIKE_OPD_METHOD.md](ZERNIKE_OPD_METHOD.md) - **critical** +3. **Mid-spatial frequency analysis?** See SYS_16 (msf_zernike insight) + +### For Structural Optimization + +- Stress extraction: See `SYS_12_EXTRACTOR_LIBRARY.md` (E3, E12) +- Strain energy: See `SYS_12_EXTRACTOR_LIBRARY.md` (E13) + +### For Thermal Analysis + +- Temperature extraction: See `SYS_12_EXTRACTOR_LIBRARY.md` (E15-E17) + +--- + +## Related Documentation + +| Location | Content | +|----------|---------| +| `.claude/skills/modules/extractors-catalog.md` | Quick extractor lookup | +| `.claude/skills/modules/insights-catalog.md` | Quick insight lookup | +| `docs/protocols/system/SYS_12_EXTRACTOR_LIBRARY.md` | Extractor specifications | +| `docs/protocols/system/SYS_16_STUDY_INSIGHTS.md` | Insight specifications | + +--- + +## Contributing Physics Documentation + +When adding new physics documentation: + +1. **Naming**: Use `{TOPIC}_{SUBTOPIC}.md` format (e.g., `THERMAL_GRADIENTS.md`) +2. **Structure**: Follow the pattern in existing documents: + - Executive Summary + - Mathematical Formulation + - When This Matters + - Implementation Details + - Usage Guide + - Validation +3. **Cross-reference**: Update this index and related skill modules +4. **Link to code**: Reference the implementing extractors/insights diff --git a/docs/ZERNIKE_INTEGRATION.md b/docs/06_PHYSICS/ZERNIKE_FUNDAMENTALS.md similarity index 87% rename from docs/ZERNIKE_INTEGRATION.md rename to docs/06_PHYSICS/ZERNIKE_FUNDAMENTALS.md index f5768b6d..8762dfd9 100644 --- a/docs/ZERNIKE_INTEGRATION.md +++ b/docs/06_PHYSICS/ZERNIKE_FUNDAMENTALS.md @@ -308,6 +308,26 @@ studies/ ## See Also +### Related Physics Documentation + +- [ZERNIKE_OPD_METHOD.md](ZERNIKE_OPD_METHOD.md) - **Rigorous OPD method for lateral displacement correction** (critical for lateral support optimization) + +### Protocol Documentation + +- `docs/protocols/system/SYS_12_EXTRACTOR_LIBRARY.md` - Extractor specifications (E8-E10: Standard Zernike, E20-E21: OPD method) +- `docs/protocols/system/SYS_16_STUDY_INSIGHTS.md` - Insight specifications (`zernike_wfe`, `zernike_opd_comparison`) + +### Skill Modules (Quick Lookup) + +- `.claude/skills/modules/extractors-catalog.md` - Quick extractor reference +- `.claude/skills/modules/insights-catalog.md` - Quick insight reference + +### Code Implementation + +- [optimization_engine/extractors/extract_zernike.py](../../optimization_engine/extractors/extract_zernike.py) - Standard Zernike extractor +- [optimization_engine/extractors/extract_zernike_opd.py](../../optimization_engine/extractors/extract_zernike_opd.py) - **OPD-based extractor** (use for lateral supports) +- [optimization_engine/extractors/zernike_helpers.py](../../optimization_engine/extractors/zernike_helpers.py) - Helper functions and objective builders + +### Example Configurations + - [examples/optimization_config_zernike_mirror.json](../examples/optimization_config_zernike_mirror.json) - Full example configuration -- [optimization_engine/extractors/extract_zernike.py](../optimization_engine/extractors/extract_zernike.py) - Core implementation -- [optimization_engine/extractors/zernike_helpers.py](../optimization_engine/extractors/zernike_helpers.py) - Helper functions diff --git a/docs/06_PHYSICS/ZERNIKE_OPD_METHOD.md b/docs/06_PHYSICS/ZERNIKE_OPD_METHOD.md new file mode 100644 index 00000000..3088a4bf --- /dev/null +++ b/docs/06_PHYSICS/ZERNIKE_OPD_METHOD.md @@ -0,0 +1,579 @@ +# Rigorous OPD-Based Zernike Analysis for Mirror Optimization + +**Document Version**: 1.0 +**Created**: 2024-12-22 +**Author**: Atomizer Framework +**Status**: Active + +--- + +## Executive Summary + +This document describes a **rigorous Optical Path Difference (OPD)** method for computing Zernike wavefront error that correctly accounts for **lateral (X, Y) displacements** in addition to axial (Z) displacements. + +**The Problem**: Standard Zernike analysis uses only Z-displacement at the original (x, y) node positions. When supports pinch the mirror or lateral forces cause in-plane deformation, nodes shift in X and Y. The standard method is **blind to this**, potentially leading to: +- Optimized designs that appear good but have poor actual optical performance +- Optimizer convergence to non-optimal solutions that "cheat" by distorting laterally + +**The Solution**: The OPD method computes the true surface error by accounting for the fact that a laterally-displaced node should be compared against the parabola height **at its new (x+dx, y+dy) position**, not its original position. + +--- + +## Table of Contents + +1. [The Optical Physics Problem](#1-the-optical-physics-problem) +2. [Mathematical Formulation](#2-mathematical-formulation) +3. [When This Matters](#3-when-this-matters) +4. [Implementation Details](#4-implementation-details) +5. [Usage Guide](#5-usage-guide) +6. [Validation and Testing](#6-validation-and-testing) +7. [Migration Guide](#7-migration-guide) + +--- + +## 1. The Optical Physics Problem + +### 1.1 What Zernike Analysis Does + +Zernike polynomials decompose a wavefront error surface into orthogonal modes: + +``` +W(r, ΞΈ) = Ξ£ cβ±Ό Zβ±Ό(r, ΞΈ) +``` + +Where: +- `W` = wavefront error (nm) +- `cβ±Ό` = Zernike coefficient for mode j +- `Zβ±Ό` = Zernike polynomial (Noll indexing) + +For a reflective mirror, the wavefront error is **twice** the surface error: +``` +WFE = 2 Γ— surface_error +``` + +### 1.2 Standard Method (Z-Only) + +The standard approach: +1. Read node original positions `(xβ‚€, yβ‚€, zβ‚€)` from BDF/DAT +2. Read displacement vector `(Ξ”x, Ξ”y, Ξ”z)` from OP2 +3. Compute surface error = `Ξ”z` (Z-displacement only) +4. Compute WFE = `2 Γ— Ξ”z Γ— nm_scale` +5. Fit Zernike at original coordinates `(xβ‚€, yβ‚€)` + +```python +# Standard method (simplified) +for nid, (dx, dy, dz) in displacements: + x, y, z = original_coords[nid] + wfe = dz * 2 * nm_scale # ONLY uses Z-displacement + X.append(x) # Original X + Y.append(y) # Original Y + WFE.append(wfe) + +coeffs = fit_zernike(X, Y, WFE) +``` + +### 1.3 The Problem: Lateral Displacement is Ignored + +Consider a node on a parabolic mirror: +- **Original position**: `(xβ‚€, yβ‚€, zβ‚€)` where `zβ‚€ = -rβ‚€Β²/(4f)` on the parabola +- **Deformed position**: `(xβ‚€+Ξ”x, yβ‚€+Ξ”y, zβ‚€+Ξ”z)` + +**Question**: What is the true surface error? + +**Standard method says**: surface_error = `Ξ”z` + +**But this is wrong!** If the node moved laterally to a new `(x, y)`, the ideal parabola has a **different** Z at that location. The node should be compared against: + +``` +z_expected = parabola(xβ‚€+Ξ”x, yβ‚€+Ξ”y) = -(xβ‚€+Ξ”x)Β² + (yβ‚€+Ξ”y)Β² / (4f) +``` + +Not against `zβ‚€ = parabola(xβ‚€, yβ‚€)`. + +### 1.4 Visual Example + +``` + Original parabola + ___ + _/ \_ + / \ + / *A \ A = original node at (xβ‚€, yβ‚€, zβ‚€) + / β†— β†˜ \ B = deformed position (xβ‚€+Ξ”x, yβ‚€+Ξ”y, zβ‚€+Ξ”z) + / B C \ C = where node SHOULD be if staying on parabola + / \ + /_____________________\ + +Standard method: error = z_B - z_A = Ξ”z + (compares B to A vertically) + +OPD method: error = z_B - z_C = Ξ”z - Ξ”z_parabola + (compares B to where parabola is at B's (x,y)) +``` + +--- + +## 2. Mathematical Formulation + +### 2.1 Differential OPD Formulation + +For a paraboloid with optical axis along Z: + +``` +z = -rΒ² / (4f) [concave mirror, vertex at origin] +``` + +Where: +- `rΒ² = xΒ² + yΒ²` +- `f` = focal length + +**Key Insight**: We can compute the **change** in parabola Z due to lateral movement: + +``` +Ξ”z_parabola = z(xβ‚€+Ξ”x, yβ‚€+Ξ”y) - z(xβ‚€, yβ‚€) + = -[(xβ‚€+Ξ”x)Β² + (yβ‚€+Ξ”y)Β²] / (4f) - [-( xβ‚€Β² + yβ‚€Β²) / (4f)] + = -[r_defΒ² - rβ‚€Β²] / (4f) + = -Ξ”rΒ² / (4f) +``` + +Where: +``` +Ξ”rΒ² = r_defΒ² - rβ‚€Β² = (xβ‚€+Ξ”x)Β² + (yβ‚€+Ξ”y)Β² - xβ‚€Β² - yβ‚€Β² + = 2Β·xβ‚€Β·Ξ”x + Ξ”xΒ² + 2Β·yβ‚€Β·Ξ”y + Ξ”yΒ² +``` + +### 2.2 True Surface Error + +The true surface error is: + +``` +surface_error = Ξ”z - Ξ”z_parabola + = Ξ”z - (-Ξ”rΒ² / 4f) + = Ξ”z + Ξ”rΒ² / (4f) +``` + +**Interpretation**: +- If a node moves **outward** (larger r), it should also move in **-Z** to stay on the concave parabola +- If the FEA says it moved by `Ξ”z`, but staying on the parabola requires `Ξ”z_parabola`, the difference is the true error +- This corrects for the "false error" that the standard method counts when nodes shift laterally + +### 2.3 Wavefront Error + +``` +WFE = 2 Γ— surface_error Γ— nm_scale + = 2 Γ— (Ξ”z - Ξ”z_parabola) Γ— nm_scale +``` + +### 2.4 Zernike Fitting Coordinates + +Another subtlety: the Zernike fit should use the **deformed** coordinates `(xβ‚€+Ξ”x, yβ‚€+Ξ”y)` rather than the original coordinates. This is because the WFE surface represents the error at the positions where the nodes **actually are** after deformation. + +```python +# OPD method +X_fit = x0 + dx # Deformed X +Y_fit = y0 + dy # Deformed Y +WFE = surface_error * 2 * nm_scale + +coeffs = fit_zernike(X_fit, Y_fit, WFE) +``` + +--- + +## 3. When This Matters + +### 3.1 Magnitude Analysis + +The correction term is: + +``` +Ξ”z_parabola = -Ξ”rΒ² / (4f) β‰ˆ -(2Β·xβ‚€Β·Ξ”x + 2Β·yβ‚€Β·Ξ”y) / (4f) [ignoring Ξ”xΒ², Ξ”yΒ²] + β‰ˆ -(xβ‚€Β·Ξ”x + yβ‚€Β·Ξ”y) / (2f) +``` + +For a node at radius `rβ‚€` with tangential displacement `Ξ”_tangential`: +- The correction is approximately: `rβ‚€ Β· Ξ”_lateral / (2f)` + +**Example**: Mirror with f = 5000 mm, outer radius = 400 mm +- Node at r = 400 mm shifts laterally by Ξ”x = 0.001 mm (1 Β΅m) +- Correction: `400 Γ— 0.001 / (2 Γ— 5000) = 0.00004 mm = 40 nm` + +This is **significant** when typical WFE is in the 10-100 nm range! + +### 3.2 Classification by Load Case + +| Load Case | Lateral Disp. | Method Impact | +|-----------|--------------|---------------| +| **Axial support** (gravity in Z) | Very small | Minimal - both methods similar | +| **Lateral support** (gravity in X/Y) | **Large** | **Significant** - OPD method required | +| **Clamp/fixture forces** | Can be large locally | May be significant at pinch points | +| **Thermal** | Variable | Depends on thermal gradients | +| **Mirror cell deflection** | Variable | Check lateral displacement magnitude | + +### 3.3 Diagnostic Thresholds + +The `ZernikeOPDExtractor` provides lateral displacement statistics: + +| Max Lateral Disp. | Recommendation | +|-------------------|----------------| +| > 10 Β΅m | **CRITICAL**: OPD method required | +| 1 - 10 Β΅m | **RECOMMENDED**: OPD method provides meaningful improvement | +| 0.1 - 1 Β΅m | **OPTIONAL**: OPD method provides minor improvement | +| < 0.1 Β΅m | **EQUIVALENT**: Both methods give essentially identical results | + +--- + +## 4. Implementation Details + +### 4.1 Module: `extract_zernike_opd.py` + +Location: `optimization_engine/extractors/extract_zernike_opd.py` + +**Key Functions**: + +```python +def compute_true_opd(x0, y0, z0, dx, dy, dz, focal_length, concave=True): + """ + Compute true surface error accounting for lateral displacement. + + Returns: + x_def: Deformed X coordinates + y_def: Deformed Y coordinates + surface_error: True surface error (not just Ξ”z) + lateral_magnitude: |Ξ”x, Ξ”y| for diagnostics + """ +``` + +```python +def estimate_focal_length_from_geometry(x, y, z, concave=True): + """ + Estimate parabola focal length by fitting z = aΒ·rΒ² + b. + Focal length = 1 / (4Β·|a|) + """ +``` + +**Main Class**: + +```python +class ZernikeOPDExtractor: + """ + Rigorous OPD-based Zernike extractor. + + Key differences from ZernikeExtractor: + - Uses deformed (x, y) coordinates for fitting + - Computes surface error relative to parabola at deformed position + - Provides lateral displacement diagnostics + """ +``` + +### 4.2 Algorithm Flow + +``` +1. Load geometry (BDF) and displacements (OP2) + +2. For each node: + a. Get original position: (xβ‚€, yβ‚€, zβ‚€) + b. Get displacement: (Ξ”x, Ξ”y, Ξ”z) + c. Compute deformed position: (x_def, y_def) = (xβ‚€+Ξ”x, yβ‚€+Ξ”y) + d. Compute Ξ”rΒ² = r_defΒ² - rβ‚€Β² + e. Compute Ξ”z_parabola = -Ξ”rΒ² / (4f) [for concave] + f. Compute surface_error = Ξ”z - Ξ”z_parabola + g. Store lateral_disp = √(Ξ”xΒ² + Ξ”yΒ²) + +3. Convert to WFE: WFE = 2 Γ— surface_error Γ— nm_scale + +4. Fit Zernike coefficients using (x_def, y_def, WFE) + +5. Compute RMS metrics: + - Global RMS = √(mean(WFEΒ²)) + - Filtered RMS = √(mean((WFE - low_order_fit)Β²)) +``` + +### 4.3 Focal Length Handling + +The extractor can: +1. Use a **provided** focal length (most accurate) +2. **Auto-estimate** from geometry by fitting `z = aΒ·rΒ² + b` + +Auto-estimation works well for clean parabolic meshes but may need manual override for: +- Off-axis parabolas +- Aspheric surfaces +- Meshes with significant manufacturing errors + +```python +# Explicit focal length +extractor = ZernikeOPDExtractor(op2_file, focal_length=5000.0) + +# Auto-estimate (default) +extractor = ZernikeOPDExtractor(op2_file, auto_estimate_focal=True) +``` + +--- + +## 5. Usage Guide + +### 5.1 Quick Comparison Test + +Run the test script to see how much the methods differ for your data: + +```bash +conda activate atomizer +python test_zernike_opd_comparison.py +``` + +Output example: +``` +--- Standard Method (Z-only) --- + Global RMS: 171.65 nm + Filtered RMS: 28.72 nm + +--- Rigorous OPD Method --- + Global RMS: 171.89 nm + Filtered RMS: 29.15 nm + +--- Difference (OPD - Standard) --- + Filtered RMS: +0.43 nm (+1.5%) + +--- Lateral Displacement --- + Max: 0.156 Β΅m + RMS: 0.111 Β΅m + +>>> OPTIONAL: Small lateral displacements. OPD method provides minor improvement. +``` + +### 5.2 Using in Optimization + +**For new studies**, use the OPD extractor: + +```python +from optimization_engine.extractors import extract_zernike_opd_filtered_rms + +def objective(trial): + # ... parameter suggestion and FEA solve ... + + # Use OPD method instead of standard + rms = extract_zernike_opd_filtered_rms( + op2_file, + subcase='20', + focal_length=5000.0 # Optional: specify or let it auto-estimate + ) + return rms +``` + +**In optimization config** (future enhancement): + +```json +{ + "objectives": [ + { + "name": "filtered_rms", + "extractor": "zernike_opd", + "extractor_config": { + "subcase": "20", + "metric": "filtered_rms_nm", + "focal_length": 5000.0 + } + } + ] +} +``` + +### 5.3 Visualization with Insights + +Generate the comparison insight for a study: + +```bash +python -m optimization_engine.insights generate studies/my_study --type zernike_opd_comparison +``` + +This creates an HTML visualization showing: +1. **Lateral displacement map** - Where pinching/lateral deformation occurs +2. **WFE surface** - Using the rigorous OPD method +3. **Comparison table** - Quantitative difference between methods +4. **Recommendation** - Whether OPD method is needed for your study + +### 5.4 API Reference + +```python +from optimization_engine.extractors import ( + # Main extractor class + ZernikeOPDExtractor, + + # Convenience functions + extract_zernike_opd, # Full metrics dict + extract_zernike_opd_filtered_rms, # Just the filtered RMS (float) + compare_zernike_methods, # Compare standard vs OPD +) + +# Full extraction with all metrics +result = extract_zernike_opd(op2_file, subcase='20') +# Returns: { +# 'filtered_rms_nm': float, +# 'global_rms_nm': float, +# 'max_lateral_disp_um': float, +# 'rms_lateral_disp_um': float, +# 'focal_length_used': float, +# 'astigmatism_rms_nm': float, +# 'coma_rms_nm': float, +# ... +# } + +# Just the primary metric for optimization +rms = extract_zernike_opd_filtered_rms(op2_file, subcase='20') + +# Compare both methods +comparison = compare_zernike_methods(op2_file, subcase='20') +# Returns: { +# 'standard_method': {'filtered_rms_nm': ...}, +# 'opd_method': {'filtered_rms_nm': ...}, +# 'delta': {'filtered_rms_nm': ..., 'percent_difference_filtered': ...}, +# 'lateral_displacement': {'max_um': ..., 'rms_um': ...}, +# 'recommendation': str +# } +``` + +--- + +## 6. Validation and Testing + +### 6.1 Analytical Test Case + +For a simple test: apply a known lateral displacement and verify the correction. + +**Setup**: +- Parabola: f = 5000 mm +- Node at (xβ‚€, yβ‚€) = (400, 0) mm, so rβ‚€ = 400 mm +- Apply uniform X-displacement: Ξ”x = 0.01 mm, Ξ”y = 0, Ξ”z = 0 + +**Expected correction**: +``` +Ξ”rΒ² = (400.01)Β² + 0Β² - 400Β² - 0Β² = 8.0001 mmΒ² +Ξ”z_parabola = -8.0001 / (4 Γ— 5000) = -0.0004 mm = -400 nm (surface) +WFE_correction = 2 Γ— 400 nm = 800 nm +``` + +**Standard method**: WFE = 2 Γ— Ξ”z Γ— 1e6 = 0 nm +**OPD method**: WFE = 2 Γ— (0 - (-0.0004)) Γ— 1e6 = 800 nm + +The OPD method correctly identifies that a purely lateral displacement **does** affect the wavefront! + +### 6.2 Sanity Checks + +The OPD method should: +1. Give **identical** results to standard method when Ξ”x = Ξ”y = 0 everywhere +2. Show **larger** WFE when nodes move outward laterally (positive Ξ”rΒ²) +3. Show **smaller** WFE when nodes move inward laterally (negative Ξ”rΒ²) +4. Scale with 1/f (larger effect for faster mirrors) + +### 6.3 Running the Test + +```bash +conda activate atomizer +python test_zernike_opd_comparison.py +``` + +--- + +## 7. Migration Guide + +### 7.1 For Existing Studies + +1. **Run comparison test** on a few representative iterations +2. **Check the difference** - if > 5%, consider re-optimizing +3. **For lateral support studies** - strongly recommend re-optimization with OPD method + +### 7.2 For New Studies + +1. **Use OPD method by default** - it's never worse than standard +2. **Specify focal length** if known (more accurate than auto-estimate) +3. **Monitor lateral displacement** in the insight reports + +### 7.3 Code Changes + +**Before** (standard method): +```python +from optimization_engine.extractors import extract_zernike_filtered_rms + +rms = extract_zernike_filtered_rms(op2_file, subcase='20') +``` + +**After** (OPD method): +```python +from optimization_engine.extractors import extract_zernike_opd_filtered_rms + +rms = extract_zernike_opd_filtered_rms(op2_file, subcase='20', focal_length=5000.0) +``` + +--- + +## Appendix A: Derivation Details + +### A.1 Full Derivation of Ξ”z_parabola + +For a concave paraboloid: `z = -rΒ²/(4f) = -(xΒ² + yΒ²)/(4f)` + +Original position: `zβ‚€ = -(xβ‚€Β² + yβ‚€Β²)/(4f)` +Deformed position: `z_expected = -((xβ‚€+Ξ”x)Β² + (yβ‚€+Ξ”y)Β²)/(4f)` + +``` +z_expected - zβ‚€ = -[(xβ‚€+Ξ”x)Β² + (yβ‚€+Ξ”y)Β² - xβ‚€Β² - yβ‚€Β²] / (4f) + = -[xβ‚€Β² + 2xβ‚€Ξ”x + Ξ”xΒ² + yβ‚€Β² + 2yβ‚€Ξ”y + Ξ”yΒ² - xβ‚€Β² - yβ‚€Β²] / (4f) + = -[2xβ‚€Ξ”x + Ξ”xΒ² + 2yβ‚€Ξ”y + Ξ”yΒ²] / (4f) + = -[r_defΒ² - rβ‚€Β²] / (4f) + = -Ξ”rΒ² / (4f) +``` + +This is `Ξ”z_parabola` - the Z change required to stay on the ideal parabola. + +### A.2 Sign Convention + +For a **concave** mirror (typical telescope primary): +- Surface curves toward -Z (vertex is the highest point) +- `z = -rΒ²/(4f)` (negative coefficient) +- Moving outward (Ξ”rΒ² > 0) requires moving in -Z direction +- `Ξ”z_parabola = -Ξ”rΒ²/(4f)` is negative for outward movement + +For a **convex** mirror: +- Surface curves toward +Z +- `z = +rΒ²/(4f)` (positive coefficient) +- `Ξ”z_parabola = +Ξ”rΒ²/(4f)` is positive for outward movement + +The `concave` parameter in the code handles this sign flip. + +--- + +## Appendix B: Files Reference + +| File | Purpose | +|------|---------| +| `optimization_engine/extractors/extract_zernike_opd.py` | Main OPD extractor implementation | +| `optimization_engine/extractors/extract_zernike.py` | Standard (Z-only) extractor | +| `optimization_engine/insights/zernike_opd_comparison.py` | Visualization insight | +| `test_zernike_opd_comparison.py` | Quick test script | + +### Related Documentation + +| Document | Purpose | +|----------|---------| +| [ZERNIKE_FUNDAMENTALS.md](ZERNIKE_FUNDAMENTALS.md) | General Zernike usage, RMS calculation, multi-subcase analysis | +| [00_INDEX.md](00_INDEX.md) | Physics documentation index | +| `.claude/skills/modules/extractors-catalog.md` | Quick extractor lookup | +| `.claude/skills/modules/insights-catalog.md` | Quick insight lookup | +| `docs/protocols/system/SYS_12_EXTRACTOR_LIBRARY.md` | Extractor specifications (E8-E10, E20-E21) | +| `docs/protocols/system/SYS_16_STUDY_INSIGHTS.md` | Insight specifications | + +--- + +## Appendix C: Glossary + +| Term | Definition | +|------|------------| +| **OPD** | Optical Path Difference - the path length difference experienced by light rays | +| **WFE** | Wavefront Error - deviation of actual wavefront from ideal (WFE = 2 Γ— surface error for reflection) | +| **Zernike polynomials** | Orthogonal basis functions for representing wavefronts over a circular aperture | +| **Noll index** | Standard optical indexing scheme for Zernike modes (j=1 is piston, j=4 is defocus, etc.) | +| **Filtered RMS** | RMS after removing low-order modes (piston, tip, tilt, defocus) that can be corrected by alignment | +| **Lateral displacement** | In-plane (X, Y) movement of nodes, as opposed to axial (Z) movement | +| **Focal length** | Distance from vertex to focus for a parabola; f = R/(2) where R is vertex radius of curvature | + +--- + +*Document maintained by Atomizer Framework. Last updated: 2024-12-22* diff --git a/docs/guides/CMA-ES_EXPLAINED.md b/docs/guides/CMA-ES_EXPLAINED.md new file mode 100644 index 00000000..6cd4d410 --- /dev/null +++ b/docs/guides/CMA-ES_EXPLAINED.md @@ -0,0 +1,212 @@ +# CMA-ES Explained for Engineers + +**CMA-ES** = **Covariance Matrix Adaptation Evolution Strategy** + +A derivative-free optimization algorithm ideal for: +- Local refinement around known good solutions +- 4-10 dimensional problems +- Smooth, continuous objective functions +- Problems where gradient information is unavailable (like FEA) + +--- + +## The Core Idea + +Imagine searching for the lowest point in a hilly landscape while blindfolded: + +1. **Throw darts** around your current best guess +2. **Observe which darts land lower** (better objective) +3. **Learn the shape of the valley** from those results +4. **Adjust future throws** to follow the valley's direction + +--- + +## Key Components + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ CMA-ES Components β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ 1. MEAN (ΞΌ) - Current best guess location β”‚ +β”‚ β€’ Moves toward better solutions each generation β”‚ +β”‚ β”‚ +β”‚ 2. STEP SIZE (Οƒ) - How far to throw darts β”‚ +β”‚ β€’ Adapts: shrinks when close, grows when exploring β”‚ +β”‚ β€’ sigma0=0.3 means 30% of parameter range initially β”‚ +β”‚ β”‚ +β”‚ 3. COVARIANCE MATRIX (C) - Shape of the search cloud β”‚ +β”‚ β€’ Learns parameter correlations β”‚ +β”‚ β€’ Stretches search along promising directions β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## Visual: How the Search Evolves + +``` +Generation 1 (Round search): Generation 10 (Learned shape): + + x x x + x x x x + x ● x ──────► x ● x + x x x x + x x x + + ● = mean (center) Ellipse aligned with + x = samples the valley direction +``` + +CMA-ES learns that certain parameter combinations work well together and stretches its search cloud in that direction. + +--- + +## The Algorithm (Simplified) + +```python +def cma_es_generation(): + # 1. SAMPLE: Generate Ξ» candidates around the mean + for i in range(population_size): + candidates[i] = mean + sigma * sample_from_gaussian(covariance=C) + + # 2. EVALUATE: Run FEA for each candidate + for candidate in candidates: + fitness[candidate] = run_simulation(candidate) + + # 3. SELECT: Keep the best ΞΌ candidates + selected = top_k(candidates, by=fitness, k=mu) + + # 4. UPDATE MEAN: Move toward the best solutions + new_mean = weighted_average(selected) + + # 5. UPDATE COVARIANCE: Learn parameter correlations + C = update_covariance(C, selected, mean, new_mean) + + # 6. UPDATE STEP SIZE: Adapt exploration range + sigma = adapt_step_size(sigma, evolution_path) +``` + +--- + +## The Covariance Matrix Magic + +Consider 4 design variables: + +``` +Covariance Matrix C (4x4): + var1 var2 var3 var4 +var1 [ 1.0 0.3 -0.5 0.1 ] +var2 [ 0.3 1.0 0.2 -0.2 ] +var3 [-0.5 0.2 1.0 0.4 ] +var4 [ 0.1 -0.2 0.4 1.0 ] +``` + +**Reading the matrix:** +- **Diagonal (1.0)**: Variance in each parameter +- **Off-diagonal**: Correlations between parameters +- **Positive (0.3)**: When var1 increases, var2 should increase +- **Negative (-0.5)**: When var1 increases, var3 should decrease + +CMA-ES **learns these correlations automatically** from simulation results! + +--- + +## CMA-ES vs TPE + +| Property | TPE | CMA-ES | +|----------|-----|--------| +| **Best for** | Global exploration | Local refinement | +| **Starting point** | Random | Known baseline | +| **Correlation learning** | None (independent) | Automatic | +| **Step size** | Fixed ranges | Adaptive | +| **Dimensionality** | Good for high-D | Best for 4-10D | +| **Sample efficiency** | Good | Excellent (locally) | + +--- + +## Optuna Configuration + +```python +from optuna.samplers import CmaEsSampler + +# Baseline values (starting point) +x0 = { + 'whiffle_min': 62.75, + 'whiffle_outer_to_vertical': 75.89, + 'whiffle_triangle_closeness': 65.65, + 'blank_backface_angle': 4.43 +} + +sampler = CmaEsSampler( + x0=x0, # Center of initial distribution + sigma0=0.3, # Initial step size (30% of range) + seed=42, # Reproducibility + restart_strategy='ipop' # Increase population on restart +) + +study = optuna.create_study(sampler=sampler, direction="minimize") + +# CRITICAL: Enqueue baseline as trial 0! +# x0 only sets the CENTER, it doesn't evaluate the baseline +study.enqueue_trial(x0) + +study.optimize(objective, n_trials=200) +``` + +--- + +## Common Pitfalls + +### 1. Not Evaluating the Baseline + +**Problem**: CMA-ES samples AROUND x0, but doesn't evaluate x0 itself. + +**Solution**: Always enqueue the baseline: +```python +if len(study.trials) == 0: + study.enqueue_trial(x0) +``` + +### 2. sigma0 Too Large or Too Small + +| sigma0 | Effect | +|--------|--------| +| **Too large (>0.5)** | Explores too far, misses local optimum | +| **Too small (<0.1)** | Gets stuck, slow convergence | +| **Recommended (0.2-0.3)** | Good balance for refinement | + +### 3. Wrong Problem Type + +CMA-ES struggles with: +- Discrete/categorical variables +- Very high dimensions (>20) +- Multi-modal landscapes (use TPE first) +- Noisy objectives (add regularization) + +--- + +## When to Use CMA-ES in Atomizer + +| Scenario | Use CMA-ES? | +|----------|-------------| +| First exploration of design space | No, use TPE | +| Refining around known good design | **Yes** | +| 4-10 continuous variables | **Yes** | +| >15 variables | No, use TPE or NSGA-II | +| Need to learn variable correlations | **Yes** | +| Multi-objective optimization | No, use NSGA-II | + +--- + +## References + +- Hansen, N. (2016). The CMA Evolution Strategy: A Tutorial +- Optuna CmaEsSampler: https://optuna.readthedocs.io/en/stable/reference/samplers/generated/optuna.samplers.CmaEsSampler.html +- cmaes Python package: https://github.com/CyberAgentAILab/cmaes + +--- + +*Created: 2025-12-19* +*Atomizer Framework* diff --git a/docs/protocols/operations/OP_01_CREATE_STUDY.md b/docs/protocols/operations/OP_01_CREATE_STUDY.md index 4ee9a6c6..e35bb229 100644 --- a/docs/protocols/operations/OP_01_CREATE_STUDY.md +++ b/docs/protocols/operations/OP_01_CREATE_STUDY.md @@ -71,6 +71,71 @@ When creating a new study: --- +## README Hierarchy (Parent-Child Documentation) + +**Two-level documentation system**: + +``` +studies/{geometry_type}/ +β”œβ”€β”€ README.md # PARENT: Project-level context +β”‚ β”œβ”€β”€ Project overview # What is this geometry/component? +β”‚ β”œβ”€β”€ Physical system specs # Material, dimensions, constraints +β”‚ β”œβ”€β”€ Optical/mechanical specs # Domain-specific requirements +β”‚ β”œβ”€β”€ Design variables catalog # ALL possible variables with descriptions +β”‚ β”œβ”€β”€ Objectives catalog # ALL possible objectives +β”‚ β”œβ”€β”€ Campaign history # Summary of all sub-studies +β”‚ └── Sub-studies index # Links to each sub-study +β”‚ +β”œβ”€β”€ sub_study_V1/ +β”‚ └── README.md # CHILD: Study-specific details +β”‚ β”œβ”€β”€ Link to parent # "See ../README.md for context" +β”‚ β”œβ”€β”€ Study focus # What THIS study optimizes +β”‚ β”œβ”€β”€ Active variables # Which params enabled +β”‚ β”œβ”€β”€ Algorithm config # Sampler, trials, settings +β”‚ β”œβ”€β”€ Baseline/seeding # Starting point +β”‚ └── Results summary # Best trial, learnings +β”‚ +└── sub_study_V2/ + └── README.md # CHILD: References parent, adds specifics +``` + +### Parent README Content (Geometry-Level) + +| Section | Content | +|---------|---------| +| Project Overview | What the component is, purpose, context | +| Physical System | Material, mass targets, loading conditions | +| Domain Specs | Optical prescription (mirrors), structural limits (brackets) | +| Design Variables | Complete catalog with ranges and descriptions | +| Objectives | All possible metrics with formulas | +| Campaign History | Evolution across sub-studies | +| Sub-Studies Index | Table with links, status, best results | +| Technical Notes | Domain-specific implementation details | + +### Child README Content (Study-Level) + +| Section | Content | +|---------|---------| +| Parent Reference | `> See [../README.md](../README.md) for project context` | +| Study Focus | What differentiates THIS study | +| Active Variables | Which parameters are enabled (subset of parent catalog) | +| Algorithm Config | Sampler, n_trials, sigma, seed | +| Baseline | Starting point (seeded from prior study or default) | +| Results | Best trial, improvement metrics | +| Key Learnings | What was discovered | + +### When to Create Parent README + +- **First study** for a geometry type β†’ Create parent README immediately +- **Subsequent studies** β†’ Add to parent's sub-studies index +- **New geometry type** β†’ Create both parent and child READMEs + +### Example Reference + +See `studies/M1_Mirror/README.md` for a complete parent README example. + +--- + ## Detailed Steps ### Step 1: Gather Requirements diff --git a/knowledge_base/lac/session_insights/failure.jsonl b/knowledge_base/lac/session_insights/failure.jsonl new file mode 100644 index 00000000..6d961cad --- /dev/null +++ b/knowledge_base/lac/session_insights/failure.jsonl @@ -0,0 +1,5 @@ +{"timestamp":"2025-12-17T20:30:00","category":"failure","context":"Killed NX process (ugraf.exe PID 111040) without permission while trying to extract expressions","insight":"CRITICAL RULE VIOLATION: Never kill NX (ugraf.exe) or any user process directly. The NXSessionManager exists specifically to track which NX sessions Atomizer started vs user sessions. Only use manager.close_nx_if_allowed() which checks can_close_nx() before terminating. Direct Stop-Process or taskkill on ugraf.exe is FORBIDDEN unless the session manager confirms we started that PID.","confidence":1.0,"tags":["nx","process-management","safety","critical","session-manager"],"severity":"critical","rule":"NEVER use Stop-Process, taskkill, or any direct process termination on ugraf.exe. Always use NXSessionManager.close_nx_if_allowed() which only closes sessions we started."} +{"timestamp":"2025-12-17T20:40:00","category":"failure","context":"Created m1_mirror_cost_reduction_V2 study without README.md despite OP_01 protocol clearly requiring it","insight":"EXECUTION FAILURE: The protocol OP_01_CREATE_STUDY.md already listed README.md as a required output, but I failed to follow my own documentation. This is a process discipline issue, not a knowledge gap. The fix is NOT to add more documentation (it was already there), but to use TodoWrite to track ALL required outputs during study creation and verify completion before declaring done. When creating a study, the todo list MUST include: (1) optimization_config.json, (2) run_optimization.py, (3) README.md, (4) STUDY_REPORT.md - and mark study creation complete ONLY after all 4 are done.","confidence":1.0,"tags":["study-creation","documentation","readme","process-discipline","todowrite"],"severity":"high","rule":"When creating a study, add ALL required files to TodoWrite checklist and verify each is created before marking task complete. The protocol exists - FOLLOW IT."} +{"timestamp":"2025-12-19T10:00:00","category":"workaround","context":"NX journal execution via cmd /c with environment variables fails silently or produces garbled output. Multiple attempts with cmd /c SET and && chaining failed to capture run_journal.exe output.","insight":"CRITICAL WORKAROUND: When executing NX journals from Claude Code on Windows, use PowerShell with [Environment]::SetEnvironmentVariable() method instead of cmd /c or $env: syntax. The correct pattern is: powershell -Command \"[Environment]::SetEnvironmentVariable('SPLM_LICENSE_SERVER', '28000@dalidou;28000@100.80.199.40', 'Process'); & 'C:\\Program Files\\Siemens\\DesigncenterNX2512\\NXBIN\\run_journal.exe' 'journal.py' -args 'arg1' 'arg2' 2>&1\". The $env: syntax gets corrupted when passed through bash (colon gets interpreted). The cmd /c SET syntax often fails to capture output. This PowerShell pattern reliably sets license server and captures all output.","confidence":1.0,"tags":["nx","powershell","run_journal","license-server","windows","cmd-workaround"],"severity":"high","rule":"ALWAYS use PowerShell with [Environment]::SetEnvironmentVariable() for NX journal execution. NEVER use cmd /c SET or $env: syntax for setting SPLM_LICENSE_SERVER."} +{"timestamp":"2025-12-19T15:30:00","category":"failure","context":"CMA-ES optimization V7 started with random sample instead of baseline. First trial had whiffle_min=45.73 instead of baseline 62.75, resulting in WS=329 instead of expected ~281.","insight":"CMA-ES with Optuna CmaEsSampler does NOT evaluate x0 (baseline) first - it samples AROUND x0 with sigma0 step size. The x0 parameter only sets the CENTER of the initial sampling distribution, not the first trial. To ensure baseline is evaluated first, use study.enqueue_trial(x0) after creating the study. This is critical for refinement studies where you need to compare against a known-good baseline. Pattern: if len(study.trials) == 0: study.enqueue_trial(x0)","confidence":1.0,"tags":["cma-es","optuna","baseline","x0","enqueue","optimization"],"severity":"high","rule":"When using CmaEsSampler with a known baseline, ALWAYS enqueue the baseline as trial 0 using study.enqueue_trial(x0). The x0 parameter alone does NOT guarantee baseline evaluation."} +{"timestamp":"2025-12-22T14:00:00","category":"failure","context":"V10 mirror optimization reported impossibly good relative WFE values (40-20=1.99nm instead of ~6nm, 60-20=6.82nm instead of ~13nm). User noticed results were 'too good to be true'.","insight":"CRITICAL BUG IN RELATIVE WFE CALCULATION: The V10 run_optimization.py computed relative WFE as abs(RMS_target - RMS_ref) instead of RMS(WFE_target - WFE_ref). This is mathematically WRONG because |RMS(A) - RMS(B)| β‰  RMS(A - B). The correct approach is to compute the node-by-node WFE difference FIRST, then fit Zernike to the difference field, then compute RMS. The bug gave values 3-4x lower than correct values because the 20Β° reference had HIGHER absolute WFE than 40Β°/60Β°, so the subtraction gave negative values, and abs() hid the problem. The fix is to use extractor.extract_relative() which correctly computes node-by-node differences. Both ZernikeExtractor and ZernikeOPDExtractor now have extract_relative() methods.","confidence":1.0,"tags":["zernike","wfe","relative-wfe","extract_relative","critical-bug","v10"],"severity":"critical","rule":"NEVER compute relative WFE as abs(RMS_target - RMS_ref). ALWAYS use extract_relative() which computes RMS(WFE_target - WFE_ref) by doing node-by-node subtraction first, then Zernike fitting, then RMS."} diff --git a/knowledge_base/lac/session_insights/success_pattern.jsonl b/knowledge_base/lac/session_insights/success_pattern.jsonl new file mode 100644 index 00000000..4c5fbaf8 --- /dev/null +++ b/knowledge_base/lac/session_insights/success_pattern.jsonl @@ -0,0 +1,3 @@ +{"timestamp":"2025-12-22T11:05:00","category":"success_pattern","context":"Organized M1 Mirror documentation with parent-child README hierarchy","insight":"DOCUMENTATION PATTERN: Studies use a two-level README hierarchy. Parent README at studies/{geometry_type}/README.md contains project-wide context (optical specs, design variables catalog, objectives catalog, campaign history, sub-studies index). Child README at studies/{geometry_type}/{study_name}/README.md references parent and contains study-specific details (active variables, algorithm config, results). This eliminates duplication, maintains single source of truth for specs, and makes sub-study docs concise. Pattern documented in OP_01_CREATE_STUDY.md and study-creation-core.md.","confidence":0.95,"tags":["documentation","readme","hierarchy","study-creation","organization"],"rule":"When creating studies for a geometry type: (1) Create parent README with project context if first study, (2) Add reference banner to child README: '> See [../README.md](../README.md) for project overview', (3) Update parent's sub-studies index table when adding new sub-studies."} +{"timestamp":"2025-12-22T11:05:00","category":"success_pattern","context":"Created universal mirror optical specs extraction tool","insight":"TOOL PATTERN: Mirror optical specs (focal length, f-number, diameter) can be auto-estimated from FEA mesh geometry by fitting z = a*rΒ² + b to node coordinates. Focal length = 1/(4*|a|). Tool at tools/extract_mirror_optical_specs.py works with any mirror study - just point it at an OP2 file or study directory. Reports fit quality to indicate if explicit focal length should be used instead. Use: python tools/extract_mirror_optical_specs.py path/to/study","confidence":0.9,"tags":["tools","mirror","optical-specs","zernike","opd","extraction"],"rule":"For mirror optimization: (1) Run extract_mirror_optical_specs.py to estimate optical prescription from mesh, (2) Validate against design specs, (3) Document in parent README, (4) Use explicit focal_length in ZernikeOPDExtractor if fit quality is poor."} +{"timestamp":"2025-12-22T11:05:00","category":"success_pattern","context":"Implemented OPD-based Zernike method for lateral support optimization","insight":"PHYSICS PATTERN: Standard Zernike WFE analysis uses Z-displacement at original (x,y) coordinates. This is INCORRECT for lateral support optimization where nodes shift in X,Y. The rigorous OPD method computes: surface_error = dz - delta_z_parabola where delta_z_parabola = -delta_rΒ²/(4f) for concave mirrors. This accounts for the fact that laterally displaced nodes should be compared against parabola height at their NEW position. Implemented in extract_zernike_opd.py with ZernikeOPDExtractor class. Use extract_comparison() to see method difference. Threshold: >10Β΅m lateral displacement = CRITICAL to use OPD.","confidence":1.0,"tags":["zernike","opd","lateral-support","mirror","wfe","physics"],"rule":"For mirror optimization with lateral supports or any case where X,Y displacement may be significant: (1) Use ZernikeOPDExtractor instead of ZernikeExtractor, (2) Run zernike_opd_comparison insight to check lateral displacement magnitude, (3) If max lateral >10Β΅m, OPD method is CRITICAL."} diff --git a/launch_dashboard.py b/launch_dashboard.py index 91cf5524..9233e890 100644 --- a/launch_dashboard.py +++ b/launch_dashboard.py @@ -53,7 +53,7 @@ def main(): # Start backend - use conda run to ensure atomizer environment print(f"{Colors.YELLOW}Starting backend server (FastAPI on port 8000)...{Colors.END}") backend_proc = subprocess.Popen( - ["conda", "run", "-n", "atomizer", "python", "-m", "uvicorn", "api.main:app", "--port", "8000"], + ["conda", "run", "-n", "atomizer", "python", "-m", "uvicorn", "api.main:app", "--port", "8000", "--reload"], cwd=str(backend_dir), shell=True, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if sys.platform == "win32" else 0 diff --git a/nx_journals/analyze_wfe_zernike.py b/nx_journals/analyze_wfe_zernike.py new file mode 100644 index 00000000..31ecdd28 --- /dev/null +++ b/nx_journals/analyze_wfe_zernike.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +""" +Atomizer Zernike WFE Analyzer +============================= + +Analyze Zernike wavefront error from NX Nastran OP2 results. + +IMPORTANT: This script requires numpy/scipy. Run from command line with +the atomizer conda environment, NOT from within NX. + +Usage: + conda activate atomizer + python analyze_wfe_zernike.py "path/to/solution.op2" + + # Or without argument - searches current directory for OP2 files: + python analyze_wfe_zernike.py + +Output: + - Zernike coefficients for each subcase + - Relative WFE metrics (filtered RMS) + - Manufacturing workload (J1-J3 filtered) + - Weighted sum calculation + +Author: Atomizer +Created: 2025-12-18 +""" + +import sys +import os +from pathlib import Path + + +def log(msg): + """Print to console.""" + print(msg) + + +def find_op2_file(working_dir=None): + """Find the most recent OP2 file in the working directory.""" + if working_dir is None: + working_dir = Path.cwd() + else: + working_dir = Path(working_dir) + + # Look for OP2 files + op2_files = list(working_dir.glob("*solution*.op2")) + list(working_dir.glob("*.op2")) + + if not op2_files: + # Check subdirectories + op2_files = list(working_dir.glob("**/*solution*.op2")) + + if not op2_files: + return None + + # Return most recently modified + return max(op2_files, key=lambda p: p.stat().st_mtime) + +def analyze_zernike(op2_path): + """Run Zernike analysis on OP2 file.""" + + # Add Atomizer to path + atomizer_root = Path(__file__).parent.parent + if str(atomizer_root) not in sys.path: + sys.path.insert(0, str(atomizer_root)) + + try: + from optimization_engine.extractors import ZernikeExtractor + except ImportError as e: + log(f"ERROR: Could not import ZernikeExtractor: {e}") + log(f"Make sure Atomizer is properly installed.") + log(f"Atomizer root: {atomizer_root}") + return None + + log("=" * 70) + log("ZERNIKE WAVEFRONT ERROR ANALYSIS") + log("=" * 70) + log(f"OP2 File: {op2_path.name}") + log(f"Directory: {op2_path.parent}") + log("") + + # Create extractor + try: + extractor = ZernikeExtractor( + op2_path, + bdf_path=None, + displacement_unit='mm', + n_modes=50, + filter_orders=4 + ) + except Exception as e: + log(f"ERROR creating extractor: {e}") + return None + + # Get available subcases from the extractor's displacement data + subcases = list(extractor.displacements.keys()) + log(f"Available subcases: {subcases}") + log("") + + # Standard subcase mapping for M1 mirror + subcase_labels = { + '1': '90 deg (Manufacturing/Polishing)', + '2': '20 deg (Reference)', + '3': '40 deg (Operational)', + '4': '60 deg (Operational)' + } + + # Extract absolute Zernike for each subcase + log("-" * 70) + log("ABSOLUTE ZERNIKE ANALYSIS (per subcase)") + log("-" * 70) + + results = {} + for sc in subcases: + try: + result = extractor.extract_subcase(sc) + results[sc] = result + label = subcase_labels.get(sc, f'Subcase {sc}') + log(f"\n{label}:") + log(f" Global RMS: {result['global_rms_nm']:.2f} nm") + log(f" Filtered RMS: {result['filtered_rms_nm']:.2f} nm (J4+ only)") + except Exception as e: + log(f" ERROR extracting subcase {sc}: {e}") + + # Relative analysis (using subcase 2 as reference) + ref_subcase = '2' + if ref_subcase in subcases: + log("") + log("-" * 70) + log(f"RELATIVE ANALYSIS (vs {subcase_labels.get(ref_subcase, ref_subcase)})") + log("-" * 70) + + relative_results = {} + for sc in subcases: + if sc == ref_subcase: + continue + try: + rel = extractor.extract_relative(sc, ref_subcase) + relative_results[sc] = rel + label = subcase_labels.get(sc, f'Subcase {sc}') + log(f"\n{label} vs Reference:") + log(f" Relative Filtered RMS: {rel['relative_filtered_rms_nm']:.2f} nm") + if 'relative_rms_filter_j1to3' in rel: + log(f" J1-J3 Filtered RMS: {rel['relative_rms_filter_j1to3']:.2f} nm") + except Exception as e: + log(f" ERROR: {e}") + + # Calculate weighted sum (M1 mirror optimization objectives) + log("") + log("-" * 70) + log("OPTIMIZATION OBJECTIVES") + log("-" * 70) + + obj_40_20 = relative_results.get('3', {}).get('relative_filtered_rms_nm', 0) + obj_60_20 = relative_results.get('4', {}).get('relative_filtered_rms_nm', 0) + obj_mfg = relative_results.get('1', {}).get('relative_rms_filter_j1to3', 0) + + log(f"\n 40-20 Filtered RMS: {obj_40_20:.2f} nm") + log(f" 60-20 Filtered RMS: {obj_60_20:.2f} nm") + log(f" MFG 90 (J1-J3): {obj_mfg:.2f} nm") + + # Weighted sums for different weight configurations + log("") + log("Weighted Sum Calculations:") + + # V4 weights: 5*40 + 5*60 + 2*mfg + mass + ws_v4 = 5*obj_40_20 + 5*obj_60_20 + 2*obj_mfg + log(f" V4 weights (5/5/2): {ws_v4:.2f} (+ mass)") + + # V5 weights: 5*40 + 5*60 + 3*mfg + mass + ws_v5 = 5*obj_40_20 + 5*obj_60_20 + 3*obj_mfg + log(f" V5 weights (5/5/3): {ws_v5:.2f} (+ mass)") + + return { + 'absolute': results, + 'relative': relative_results, + 'objectives': { + '40_20': obj_40_20, + '60_20': obj_60_20, + 'mfg_90': obj_mfg, + 'ws_v4': ws_v4, + 'ws_v5': ws_v5 + } + } + + return {'absolute': results} + +def main(args): + """Main entry point.""" + log("") + log("=" * 70) + log(" ATOMIZER ZERNIKE WFE ANALYZER") + log("=" * 70) + log("") + + # Determine OP2 file + op2_path = None + + if args and len(args) > 0 and args[0]: + # OP2 path provided as argument + op2_path = Path(args[0]) + if not op2_path.exists(): + log(f"ERROR: OP2 file not found: {op2_path}") + return + else: + # Try to find OP2 in current directory + log("No OP2 file specified, searching...") + op2_path = find_op2_file() + + if op2_path is None: + log("ERROR: No OP2 file found in current directory.") + log("Usage: Run after solving, or provide OP2 path as argument.") + return + + log(f"Found: {op2_path}") + + # Run analysis + results = analyze_zernike(op2_path) + + if results: + log("") + log("=" * 70) + log("ANALYSIS COMPLETE") + log("=" * 70) + else: + log("") + log("Analysis failed. Check errors above.") + +if __name__ == '__main__': + # Get arguments (works both in NX and command line) + if len(sys.argv) > 1: + main(sys.argv[1:]) + else: + main([]) diff --git a/nx_journals/capture_study_images.py b/nx_journals/capture_study_images.py new file mode 100644 index 00000000..d4306d45 --- /dev/null +++ b/nx_journals/capture_study_images.py @@ -0,0 +1,184 @@ +# NX Journal: Capture Study Images for Atomizer Documentation +# +# Purpose: Capture top view and isometric view images of a part for study documentation +# Usage: run_journal.exe capture_study_images.py -args "part_file_path" "output_directory" ["prefix"] +# +# Arguments: +# part_file_path: Full path to the .prt file to capture +# output_directory: Directory where images will be saved +# prefix (optional): Prefix for image filenames (default: part name) +# +# Output: +# {prefix}_Top.png - Top view image +# {prefix}_iso.png - Isometric view image +# +# Author: Atomizer +# Created: 2025-12-18 + +import sys +import os +import math +import NXOpen +import NXOpen.Gateway + + +def capture_images(part_path: str, output_dir: str, prefix: str = None): + """ + Capture top view and isometric view images of a part. + + Args: + part_path: Full path to the .prt file + output_dir: Directory to save images + prefix: Optional prefix for image filenames + """ + theSession = NXOpen.Session.GetSession() + + # Open the part if not already open + try: + workPart, loadStatus = theSession.Parts.OpenDisplay(part_path, NXOpen.Part.LoadStatically) + loadStatus.Dispose() + except: + workPart = theSession.Parts.Work + + if workPart is None: + print(f"ERROR: Could not open part: {part_path}") + return False + + # Determine prefix from part name if not provided + if prefix is None: + prefix = os.path.splitext(os.path.basename(part_path))[0] + + # Ensure output directory exists + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + # Hide construction geometry for cleaner images + _hide_construction_geometry(theSession, workPart) + + # Capture top view + top_image_path = os.path.join(output_dir, f"{prefix}_Top.png") + _capture_top_view(theSession, workPart, top_image_path) + print(f"Saved: {top_image_path}") + + # Capture isometric view + iso_image_path = os.path.join(output_dir, f"{prefix}_iso.png") + _capture_isometric_view(theSession, workPart, iso_image_path) + print(f"Saved: {iso_image_path}") + + return True + + +def _hide_construction_geometry(theSession, workPart): + """Hide datums, curves, and sketches for cleaner visualization.""" + markId = theSession.SetUndoMark(NXOpen.Session.MarkVisibility.Visible, "Hide Construction") + + # Hide datums + theSession.DisplayManager.HideByType("SHOW_HIDE_TYPE_DATUMS", + NXOpen.DisplayManager.ShowHideScope.AnyInAssembly) + + # Hide curves + theSession.DisplayManager.HideByType("SHOW_HIDE_TYPE_CURVES", + NXOpen.DisplayManager.ShowHideScope.AnyInAssembly) + + # Hide sketches + theSession.DisplayManager.HideByType("SHOW_HIDE_TYPE_SKETCHES", + NXOpen.DisplayManager.ShowHideScope.AnyInAssembly) + + theSession.UpdateManager.DoUpdate(markId) + workPart.ModelingViews.WorkView.FitAfterShowOrHide(NXOpen.View.ShowOrHideType.HideOnly) + theSession.DeleteUndoMark(markId, None) + + +def _capture_top_view(theSession, workPart, output_path): + """Capture top view (looking down Z-axis).""" + # Set top view orientation (looking down -Z) + matrix = NXOpen.Matrix3x3() + matrix.Xx = 0.0 + matrix.Xy = -1.0 + matrix.Xz = 0.0 + matrix.Yx = -1.0 + matrix.Yy = 0.0 + matrix.Yz = 0.0 + matrix.Zx = 0.0 + matrix.Zy = 0.0 + matrix.Zz = -1.0 + workPart.ModelingViews.WorkView.Orient(matrix) + + # Fit view + workPart.ModelingViews.WorkView.Fit() + + # Export image + _export_image(workPart, output_path) + + +def _capture_isometric_view(theSession, workPart, output_path): + """Capture isometric view (standard ISO angle showing backface).""" + # Set isometric orientation showing backface structure + rotMatrix = NXOpen.Matrix3x3() + rotMatrix.Xx = -0.32736574141345925 + rotMatrix.Xy = -0.94489752125198745 + rotMatrix.Xz = -0.00058794613984273266 + rotMatrix.Yx = -0.71924452681462514 + rotMatrix.Yy = 0.24959027079525001 + rotMatrix.Yz = -0.64837643955618585 + rotMatrix.Zx = 0.61279603621108569 + rotMatrix.Zy = -0.21183335680718612 + rotMatrix.Zz = -0.76131967460967154 + + # Get current scale and set orientation + translation = NXOpen.Point3d(0, 0, 0) + workPart.ModelingViews.WorkView.SetRotationTranslationScale(rotMatrix, translation, 0.25) + + # Fit view + workPart.ModelingViews.WorkView.Fit() + + # Export image + _export_image(workPart, output_path) + + +def _export_image(workPart, output_path, width=1200, height=1000): + """Export current view as PNG image.""" + imageExportBuilder = workPart.Views.CreateImageExportBuilder() + + try: + # Configure export settings + imageExportBuilder.RegionMode = False # Use entire view + imageExportBuilder.DeviceWidth = width + imageExportBuilder.DeviceHeight = height + imageExportBuilder.FileFormat = NXOpen.Gateway.ImageExportBuilder.FileFormats.Png + imageExportBuilder.FileName = output_path + imageExportBuilder.BackgroundOption = NXOpen.Gateway.ImageExportBuilder.BackgroundOptions.Original + imageExportBuilder.EnhanceEdges = False + + # Commit export + imageExportBuilder.Commit() + finally: + imageExportBuilder.Destroy() + + +def main(args): + """Main entry point for journal.""" + if len(args) < 2: + print("Usage: capture_study_images.py -args \"part_path\" \"output_dir\" [\"prefix\"]") + print(" part_path: Full path to .prt file") + print(" output_dir: Directory for output images") + print(" prefix: Optional filename prefix (default: part name)") + return + + part_path = args[0] + output_dir = args[1] + prefix = args[2] if len(args) > 2 else None + + print(f"Capturing images for: {part_path}") + print(f"Output directory: {output_dir}") + + success = capture_images(part_path, output_dir, prefix) + + if success: + print("Image capture complete!") + else: + print("Image capture failed!") + + +if __name__ == '__main__': + main(sys.argv[1:]) diff --git a/nx_journals/extract_expressions.py b/nx_journals/extract_expressions.py new file mode 100644 index 00000000..0e9762b7 --- /dev/null +++ b/nx_journals/extract_expressions.py @@ -0,0 +1,111 @@ +""" +NX Journal Script to Extract All Expressions from a Part + +Usage: + run_journal.exe extract_expressions.py [output_dir] + +Output: + _temp_expressions.json with all expressions from the part +""" + +import sys +import os +import json +import NXOpen + + +def main(args): + if len(args) < 1: + print("ERROR: No .prt file path provided") + return False + + prt_file_path = args[0] + output_dir = args[1] if len(args) > 1 else os.path.dirname(prt_file_path) + + print(f"[JOURNAL] Extracting expressions from: {os.path.basename(prt_file_path)}") + + results = { + 'part_file': os.path.basename(prt_file_path), + 'part_path': prt_file_path, + 'expressions': [], + 'expression_count': 0, + 'user_expression_count': 0, + 'success': False, + 'error': None + } + + try: + theSession = NXOpen.Session.GetSession() + + # Set load options + working_dir = os.path.dirname(prt_file_path) + theSession.Parts.LoadOptions.ComponentLoadMethod = NXOpen.LoadOptions.LoadMethod.FromDirectory + theSession.Parts.LoadOptions.SetSearchDirectories([working_dir], [True]) + + # Open the part file + print(f"[JOURNAL] Opening part file...") + basePart, partLoadStatus = theSession.Parts.OpenActiveDisplay( + prt_file_path, + NXOpen.DisplayPartOption.AllowAdditional + ) + partLoadStatus.Dispose() + + workPart = theSession.Parts.Work + print(f"[JOURNAL] Loaded part: {workPart.Name}") + + # Extract all expressions + print(f"[JOURNAL] Extracting expressions...") + for expr in workPart.Expressions: + try: + expr_data = { + 'name': expr.Name, + 'value': expr.Value, + 'rhs': expr.RightHandSide if hasattr(expr, 'RightHandSide') else None, + 'units': expr.Units.Name if expr.Units else None, + 'type': str(expr.Type) if hasattr(expr, 'Type') else 'Unknown', + } + + # Check if it's a user expression (not internal p0, p1, etc.) + is_internal = expr.Name.startswith('p') and len(expr.Name) > 1 and expr.Name[1:].replace('.', '').replace('_', '').isdigit() + expr_data['is_internal'] = is_internal + + results['expressions'].append(expr_data) + + if not is_internal: + results['user_expression_count'] += 1 + + except Exception as e: + print(f"[JOURNAL] Warning: Could not read expression: {e}") + + results['expression_count'] = len(results['expressions']) + results['success'] = True + + print(f"[JOURNAL] Found {results['expression_count']} total expressions") + print(f"[JOURNAL] Found {results['user_expression_count']} user expressions") + + # Print user expressions + print(f"\n[JOURNAL] USER EXPRESSIONS:") + print(f"[JOURNAL] " + "=" * 50) + for expr in results['expressions']: + if not expr['is_internal']: + units_str = f" [{expr['units']}]" if expr['units'] else "" + print(f"[JOURNAL] {expr['name']}: {expr['value']}{units_str}") + + except Exception as e: + results['error'] = str(e) + results['success'] = False + print(f"[JOURNAL] ERROR: {e}") + import traceback + traceback.print_exc() + + # Write results + output_file = os.path.join(output_dir, "_temp_expressions.json") + with open(output_file, 'w') as f: + json.dump(results, f, indent=2) + print(f"\n[JOURNAL] Results written to: {output_file}") + + return results['success'] + + +if __name__ == '__main__': + main(sys.argv[1:]) diff --git a/nx_journals/extract_expressions_standalone.py b/nx_journals/extract_expressions_standalone.py new file mode 100644 index 00000000..ac3e0780 --- /dev/null +++ b/nx_journals/extract_expressions_standalone.py @@ -0,0 +1,96 @@ +""" +Standalone expression extractor - opens part and extracts all expressions +Run with: ugraf.exe -run extract_expressions_standalone.py +""" +import NXOpen +import os +import json + +def main(): + session = NXOpen.Session.GetSession() + + part_path = r"C:\Users\antoi\Atomizer\studies\m1_mirror_cost_reduction\1_setup\model\M1_Blank.prt" + output_json = r"C:\Users\antoi\Atomizer\_expressions_output.json" + output_txt = r"C:\Users\antoi\Atomizer\_expressions_output.txt" + + results = {'expressions': [], 'success': False, 'part': part_path} + output_lines = [] + + try: + # Set load options + working_dir = os.path.dirname(part_path) + session.Parts.LoadOptions.ComponentLoadMethod = NXOpen.LoadOptions.LoadMethod.FromDirectory + session.Parts.LoadOptions.SetSearchDirectories([working_dir], [True]) + + # Open the part + output_lines.append(f"Opening: {part_path}") + basePart, loadStatus = session.Parts.OpenActiveDisplay( + part_path, + NXOpen.DisplayPartOption.AllowAdditional + ) + loadStatus.Dispose() + + workPart = session.Parts.Work + output_lines.append(f"Loaded: {workPart.Name}") + output_lines.append("") + output_lines.append("=" * 60) + output_lines.append("EXPRESSIONS IN M1_Blank.prt") + output_lines.append("=" * 60) + + # Extract expressions + for expr in workPart.Expressions: + try: + name = expr.Name + + # Skip internal expressions (p0, p1, p123, etc.) + if name.startswith('p') and len(name) > 1: + rest = name[1:] + # Check if rest is numeric (possibly with dots for decimals) + if rest.replace('.', '').replace('_', '').isdigit(): + continue + + value = expr.Value + units = expr.Units.Name if expr.Units else '' + rhs = expr.RightHandSide if hasattr(expr, 'RightHandSide') else '' + + results['expressions'].append({ + 'name': name, + 'value': value, + 'units': units, + 'formula': rhs + }) + + units_str = f" [{units}]" if units else "" + output_lines.append(f"{name}: {value}{units_str}") + + except Exception as e: + output_lines.append(f"Error reading expression: {e}") + + results['success'] = True + results['count'] = len(results['expressions']) + output_lines.append("") + output_lines.append(f"Total user expressions: {results['count']}") + + except Exception as e: + import traceback + results['error'] = str(e) + results['traceback'] = traceback.format_exc() + output_lines.append(f"ERROR: {e}") + output_lines.append(traceback.format_exc()) + + # Write outputs + with open(output_json, 'w') as f: + json.dump(results, f, indent=2) + + with open(output_txt, 'w') as f: + f.write('\n'.join(output_lines)) + + # Exit NX + try: + session.Parts.Work.Close(NXOpen.BasePart.CloseWholeTree.FalseValue, + NXOpen.BasePart.CloseModified.CloseModified, None) + except: + pass + +if __name__ == '__main__': + main() diff --git a/nx_journals/introspect_part.py b/nx_journals/introspect_part.py new file mode 100644 index 00000000..198cb7c5 --- /dev/null +++ b/nx_journals/introspect_part.py @@ -0,0 +1,620 @@ +""" +NX Journal: Comprehensive Part Introspection Tool +=================================================== + +This journal performs deep introspection of an NX .prt file and extracts: +- All expressions (user and internal, with values, units, formulas) +- Mass properties (mass, volume, surface area, center of gravity) +- Material properties (name, density, all material attributes) +- Body information (solid bodies, sheet bodies, body attributes) +- Part attributes (all user-defined attributes) +- Groups (all groups and their members) +- Features (all features in the part) +- References (linked parts, assembly components) +- Datum planes, coordinate systems +- Units system + +Usage: + run_journal.exe introspect_part.py [output_dir] + +Output: + _temp_introspection.json - Comprehensive JSON with all extracted data + +Author: Atomizer +Created: 2025-12-19 +Version: 1.0 +""" + +import sys +import os +import json +import NXOpen +import NXOpen.UF + + +def get_expressions(part): + """Extract all expressions from the part.""" + expressions = { + 'user': [], + 'internal': [], + 'total_count': 0, + 'user_count': 0 + } + + try: + for expr in part.Expressions: + try: + expr_data = { + 'name': expr.Name, + 'value': expr.Value, + 'rhs': expr.RightHandSide if hasattr(expr, 'RightHandSide') else None, + 'units': expr.Units.Name if expr.Units else None, + 'type': str(expr.Type) if hasattr(expr, 'Type') else 'Unknown', + } + + # Determine if internal (p0, p1, p123, etc.) + name = expr.Name + is_internal = False + if name.startswith('p') and len(name) > 1: + rest = name[1:].replace('.', '').replace('_', '') + if rest.isdigit(): + is_internal = True + + if is_internal: + expressions['internal'].append(expr_data) + else: + expressions['user'].append(expr_data) + + except Exception as e: + pass + + expressions['total_count'] = len(expressions['user']) + len(expressions['internal']) + expressions['user_count'] = len(expressions['user']) + + except Exception as e: + expressions['error'] = str(e) + + return expressions + + +def get_all_solid_bodies(part): + """Get all solid bodies from the part.""" + bodies = [] + try: + for body in part.Bodies: + if body.IsSolidBody: + bodies.append(body) + except Exception as e: + pass + return bodies + + +def get_mass_properties(part, bodies): + """Extract mass properties using MeasureManager.""" + results = { + 'mass_kg': 0.0, + 'mass_g': 0.0, + 'volume_mm3': 0.0, + 'surface_area_mm2': 0.0, + 'center_of_gravity_mm': [0.0, 0.0, 0.0], + 'num_bodies': len(bodies), + 'success': False + } + + if not bodies: + return results + + try: + measureManager = part.MeasureManager + bodyArray = list(bodies) + + # Build mass_units array + uc = part.UnitCollection + mass_units = [ + uc.GetBase("Area"), + uc.GetBase("Volume"), + uc.GetBase("Mass"), + uc.GetBase("Length") + ] + + measureBodies = measureManager.NewMassProperties(mass_units, 0.99, bodyArray) + + if measureBodies: + try: + results['mass_kg'] = measureBodies.Mass + results['mass_g'] = results['mass_kg'] * 1000.0 + except: + pass + + try: + results['volume_mm3'] = measureBodies.Volume + except: + pass + + try: + results['surface_area_mm2'] = measureBodies.Area + except: + pass + + try: + cog = measureBodies.Centroid + if cog: + results['center_of_gravity_mm'] = [cog.X, cog.Y, cog.Z] + except: + pass + + results['success'] = True + + try: + measureBodies.Dispose() + except: + pass + + except Exception as e: + results['error'] = str(e) + + return results + + +def get_materials(part, bodies): + """Extract all materials from the part.""" + materials = { + 'assigned': [], + 'available': [], + 'library': [] + } + + # Get materials assigned to bodies + for body in bodies: + try: + phys_mat = body.GetPhysicalMaterial() + if phys_mat: + mat_info = { + 'name': phys_mat.Name, + 'body': body.Name if hasattr(body, 'Name') else 'Unknown', + 'properties': {} + } + + # Try to get common material properties + prop_names = ['Density', 'YoungModulus', 'PoissonRatio', + 'ThermalExpansionCoefficient', 'ThermalConductivity', + 'SpecificHeat', 'YieldStrength', 'UltimateStrength'] + for prop_name in prop_names: + try: + val = phys_mat.GetPropertyValue(prop_name) + if val is not None: + mat_info['properties'][prop_name] = float(val) + except: + pass + + materials['assigned'].append(mat_info) + except: + pass + + # Get all materials in part via PhysicalMaterialManager + try: + pmm = part.PhysicalMaterialManager + if pmm: + all_mats = pmm.GetAllPhysicalMaterials() + for mat in all_mats: + try: + mat_info = { + 'name': mat.Name, + 'properties': {} + } + prop_names = ['Density', 'YoungModulus', 'PoissonRatio'] + for prop_name in prop_names: + try: + val = mat.GetPropertyValue(prop_name) + if val is not None: + mat_info['properties'][prop_name] = float(val) + except: + pass + materials['available'].append(mat_info) + except: + pass + except Exception as e: + materials['pmm_error'] = str(e) + + return materials + + +def get_body_info(part): + """Get detailed body information.""" + body_info = { + 'solid_bodies': [], + 'sheet_bodies': [], + 'counts': { + 'solid': 0, + 'sheet': 0, + 'total': 0 + } + } + + try: + for body in part.Bodies: + body_data = { + 'name': body.Name if hasattr(body, 'Name') else 'Unknown', + 'is_solid': body.IsSolidBody, + 'is_sheet': body.IsSheetBody if hasattr(body, 'IsSheetBody') else False, + 'attributes': [] + } + + # Get body attributes + try: + attrs = body.GetUserAttributes() + for attr in attrs: + try: + body_data['attributes'].append({ + 'title': attr.Title, + 'type': str(attr.Type), + 'value': attr.StringValue if hasattr(attr, 'StringValue') else str(attr.Value) + }) + except: + pass + except: + pass + + if body.IsSolidBody: + body_info['solid_bodies'].append(body_data) + body_info['counts']['solid'] += 1 + else: + body_info['sheet_bodies'].append(body_data) + body_info['counts']['sheet'] += 1 + + body_info['counts']['total'] = body_info['counts']['solid'] + body_info['counts']['sheet'] + + except Exception as e: + body_info['error'] = str(e) + + return body_info + + +def get_part_attributes(part): + """Get all part-level attributes.""" + attributes = [] + + try: + attrs = part.GetUserAttributes() + for attr in attrs: + try: + attr_data = { + 'title': attr.Title, + 'type': str(attr.Type), + } + + # Get value based on type + try: + if hasattr(attr, 'StringValue'): + attr_data['value'] = attr.StringValue + elif hasattr(attr, 'Value'): + attr_data['value'] = attr.Value + elif hasattr(attr, 'IntegerValue'): + attr_data['value'] = attr.IntegerValue + elif hasattr(attr, 'RealValue'): + attr_data['value'] = attr.RealValue + except: + attr_data['value'] = 'Unknown' + + attributes.append(attr_data) + except: + pass + + except Exception as e: + pass + + return attributes + + +def get_groups(part): + """Get all groups in the part.""" + groups = [] + + try: + # NX stores groups in a collection + if hasattr(part, 'Groups'): + for group in part.Groups: + try: + group_data = { + 'name': group.Name if hasattr(group, 'Name') else 'Unknown', + 'member_count': 0, + 'members': [] + } + + # Try to get group members + try: + members = group.GetMembers() + group_data['member_count'] = len(members) if members else 0 + for member in members[:10]: # Limit to first 10 for readability + try: + group_data['members'].append(str(type(member).__name__)) + except: + pass + except: + pass + + groups.append(group_data) + except: + pass + except Exception as e: + pass + + return groups + + +def get_features(part): + """Get summary of features in the part.""" + features = { + 'total_count': 0, + 'by_type': {}, + 'first_10': [] + } + + try: + count = 0 + for feature in part.Features: + try: + feat_type = str(type(feature).__name__) + + # Count by type + if feat_type in features['by_type']: + features['by_type'][feat_type] += 1 + else: + features['by_type'][feat_type] = 1 + + # Store first 10 for reference + if count < 10: + features['first_10'].append({ + 'name': feature.Name if hasattr(feature, 'Name') else 'Unknown', + 'type': feat_type + }) + + count += 1 + except: + pass + + features['total_count'] = count + + except Exception as e: + features['error'] = str(e) + + return features + + +def get_datums(part): + """Get datum planes and coordinate systems.""" + datums = { + 'planes': [], + 'csys': [], + 'axes': [] + } + + try: + # Datum planes + if hasattr(part, 'Datums'): + for datum in part.Datums: + try: + datum_type = str(type(datum).__name__) + datum_name = datum.Name if hasattr(datum, 'Name') else 'Unknown' + + if 'Plane' in datum_type: + datums['planes'].append(datum_name) + elif 'Csys' in datum_type or 'Coordinate' in datum_type: + datums['csys'].append(datum_name) + elif 'Axis' in datum_type: + datums['axes'].append(datum_name) + except: + pass + except Exception as e: + datums['error'] = str(e) + + return datums + + +def get_units_info(part): + """Get unit system information.""" + units_info = { + 'base_units': {}, + 'system': 'Unknown' + } + + try: + uc = part.UnitCollection + + # Get common base units + unit_types = ['Length', 'Mass', 'Time', 'Temperature', 'Angle', + 'Area', 'Volume', 'Force', 'Pressure', 'Density'] + for unit_type in unit_types: + try: + base_unit = uc.GetBase(unit_type) + if base_unit: + units_info['base_units'][unit_type] = base_unit.Name + except: + pass + + # Determine system from length unit + if 'Length' in units_info['base_units']: + length_unit = units_info['base_units']['Length'].lower() + if 'mm' in length_unit or 'millimeter' in length_unit: + units_info['system'] = 'Metric (mm)' + elif 'meter' in length_unit and 'milli' not in length_unit: + units_info['system'] = 'Metric (m)' + elif 'inch' in length_unit or 'in' in length_unit: + units_info['system'] = 'Imperial (inch)' + + except Exception as e: + units_info['error'] = str(e) + + return units_info + + +def get_linked_parts(theSession, working_dir): + """Get information about linked/associated parts.""" + linked_parts = { + 'loaded_parts': [], + 'fem_parts': [], + 'sim_parts': [], + 'idealized_parts': [] + } + + try: + for part in theSession.Parts: + try: + part_name = part.Name if hasattr(part, 'Name') else str(part) + part_path = part.FullPath if hasattr(part, 'FullPath') else 'Unknown' + + part_info = { + 'name': part_name, + 'path': part_path, + 'leaf_name': part.Leaf if hasattr(part, 'Leaf') else part_name + } + + name_lower = part_name.lower() + if '_sim' in name_lower or name_lower.endswith('.sim'): + linked_parts['sim_parts'].append(part_info) + elif '_fem' in name_lower or name_lower.endswith('.fem'): + if '_i.prt' in name_lower or '_i' in name_lower: + linked_parts['idealized_parts'].append(part_info) + else: + linked_parts['fem_parts'].append(part_info) + elif '_i.prt' in name_lower: + linked_parts['idealized_parts'].append(part_info) + else: + linked_parts['loaded_parts'].append(part_info) + + except: + pass + + except Exception as e: + linked_parts['error'] = str(e) + + return linked_parts + + +def main(args): + """Main entry point for NX journal.""" + + if len(args) < 1: + print("ERROR: No .prt file path provided") + print("Usage: run_journal.exe introspect_part.py [output_dir]") + return False + + prt_file_path = args[0] + output_dir = args[1] if len(args) > 1 else os.path.dirname(prt_file_path) + prt_filename = os.path.basename(prt_file_path) + + print(f"[INTROSPECT] " + "="*60) + print(f"[INTROSPECT] NX COMPREHENSIVE PART INTROSPECTION") + print(f"[INTROSPECT] " + "="*60) + print(f"[INTROSPECT] Part: {prt_filename}") + print(f"[INTROSPECT] Output: {output_dir}") + + results = { + 'part_file': prt_filename, + 'part_path': prt_file_path, + 'success': False, + 'error': None, + 'expressions': {}, + 'mass_properties': {}, + 'materials': {}, + 'bodies': {}, + 'attributes': [], + 'groups': [], + 'features': {}, + 'datums': {}, + 'units': {}, + 'linked_parts': {} + } + + try: + theSession = NXOpen.Session.GetSession() + + # Set load options + working_dir = os.path.dirname(prt_file_path) + theSession.Parts.LoadOptions.ComponentLoadMethod = NXOpen.LoadOptions.LoadMethod.FromDirectory + theSession.Parts.LoadOptions.SetSearchDirectories([working_dir], [True]) + + # Open the part file + print(f"[INTROSPECT] Opening part file...") + basePart, partLoadStatus = theSession.Parts.OpenActiveDisplay( + prt_file_path, + NXOpen.DisplayPartOption.AllowAdditional + ) + partLoadStatus.Dispose() + + workPart = theSession.Parts.Work + print(f"[INTROSPECT] Loaded: {workPart.Name}") + + # Extract all data + print(f"[INTROSPECT] Extracting expressions...") + results['expressions'] = get_expressions(workPart) + print(f"[INTROSPECT] Found {results['expressions']['user_count']} user expressions") + + print(f"[INTROSPECT] Extracting body info...") + results['bodies'] = get_body_info(workPart) + print(f"[INTROSPECT] Found {results['bodies']['counts']['solid']} solid bodies") + + print(f"[INTROSPECT] Extracting mass properties...") + bodies = get_all_solid_bodies(workPart) + results['mass_properties'] = get_mass_properties(workPart, bodies) + print(f"[INTROSPECT] Mass: {results['mass_properties']['mass_kg']:.4f} kg") + + print(f"[INTROSPECT] Extracting materials...") + results['materials'] = get_materials(workPart, bodies) + print(f"[INTROSPECT] Found {len(results['materials']['assigned'])} assigned materials") + + print(f"[INTROSPECT] Extracting attributes...") + results['attributes'] = get_part_attributes(workPart) + print(f"[INTROSPECT] Found {len(results['attributes'])} part attributes") + + print(f"[INTROSPECT] Extracting groups...") + results['groups'] = get_groups(workPart) + print(f"[INTROSPECT] Found {len(results['groups'])} groups") + + print(f"[INTROSPECT] Extracting features...") + results['features'] = get_features(workPart) + print(f"[INTROSPECT] Found {results['features']['total_count']} features") + + print(f"[INTROSPECT] Extracting datums...") + results['datums'] = get_datums(workPart) + print(f"[INTROSPECT] Found {len(results['datums']['planes'])} datum planes") + + print(f"[INTROSPECT] Extracting units...") + results['units'] = get_units_info(workPart) + print(f"[INTROSPECT] System: {results['units']['system']}") + + print(f"[INTROSPECT] Extracting linked parts...") + results['linked_parts'] = get_linked_parts(theSession, working_dir) + print(f"[INTROSPECT] Found {len(results['linked_parts']['loaded_parts'])} loaded parts") + + results['success'] = True + print(f"[INTROSPECT] ") + print(f"[INTROSPECT] INTROSPECTION COMPLETE!") + print(f"[INTROSPECT] " + "="*60) + + # Summary + print(f"[INTROSPECT] SUMMARY:") + print(f"[INTROSPECT] Expressions: {results['expressions']['user_count']} user, {len(results['expressions']['internal'])} internal") + print(f"[INTROSPECT] Mass: {results['mass_properties']['mass_kg']:.4f} kg ({results['mass_properties']['mass_g']:.2f} g)") + print(f"[INTROSPECT] Bodies: {results['bodies']['counts']['solid']} solid, {results['bodies']['counts']['sheet']} sheet") + print(f"[INTROSPECT] Features: {results['features']['total_count']}") + print(f"[INTROSPECT] Materials: {len(results['materials']['assigned'])} assigned") + + except Exception as e: + results['error'] = str(e) + results['success'] = False + print(f"[INTROSPECT] FATAL ERROR: {e}") + import traceback + traceback.print_exc() + + # Write results + output_file = os.path.join(output_dir, "_temp_introspection.json") + with open(output_file, 'w') as f: + json.dump(results, f, indent=2) + print(f"[INTROSPECT] Results written to: {output_file}") + + return results['success'] + + +if __name__ == '__main__': + main(sys.argv[1:]) diff --git a/nx_journals/list_expressions_simple.py b/nx_journals/list_expressions_simple.py new file mode 100644 index 00000000..b6f705de --- /dev/null +++ b/nx_journals/list_expressions_simple.py @@ -0,0 +1,55 @@ +"""Simple expression lister - writes to file regardless of print issues""" +import NXOpen +import os +import json + +session = NXOpen.Session.GetSession() +output_lines = [] +results = {'expressions': [], 'success': False} + +try: + # Get all open parts and find M1_Blank + for part in session.Parts: + part_name = part.Name if hasattr(part, 'Name') else str(part) + if 'M1_Blank' in part_name and '_fem' not in part_name.lower() and '_i' not in part_name.lower(): + output_lines.append(f"Found part: {part_name}") + + for expr in part.Expressions: + try: + name = expr.Name + # Skip internal expressions (p0, p1, etc.) + if name.startswith('p') and len(name) > 1: + rest = name[1:].replace('.', '').replace('_', '') + if rest.isdigit(): + continue + + value = expr.Value + units = expr.Units.Name if expr.Units else '' + rhs = expr.RightHandSide if hasattr(expr, 'RightHandSide') else '' + + results['expressions'].append({ + 'name': name, + 'value': value, + 'units': units, + 'rhs': rhs + }) + output_lines.append(f"{name}: {value} {units}") + except: + pass + + results['success'] = True + break + +except Exception as e: + output_lines.append(f"Error: {str(e)}") + results['error'] = str(e) + +# Write to file +output_path = r"C:\Users\antoi\Atomizer\_expressions_output.json" +with open(output_path, 'w') as f: + json.dump(results, f, indent=2) + +# Also write text version +text_path = r"C:\Users\antoi\Atomizer\_expressions_output.txt" +with open(text_path, 'w') as f: + f.write('\n'.join(output_lines)) diff --git a/nx_journals/test_write.py b/nx_journals/test_write.py new file mode 100644 index 00000000..09a7da7c --- /dev/null +++ b/nx_journals/test_write.py @@ -0,0 +1,11 @@ +"""Simple test - just write a file""" +with open(r"C:\Users\antoi\Atomizer\_test_output.txt", 'w') as f: + f.write("Journal executed successfully!\n") + + try: + import NXOpen + f.write("NXOpen imported OK\n") + session = NXOpen.Session.GetSession() + f.write(f"Session: {session}\n") + except Exception as e: + f.write(f"NXOpen error: {e}\n") diff --git a/nx_journals/user_generated_journals/journal_top_view_image_taking.py b/nx_journals/user_generated_journals/journal_top_view_image_taking.py new file mode 100644 index 00000000..165faa97 --- /dev/null +++ b/nx_journals/user_generated_journals/journal_top_view_image_taking.py @@ -0,0 +1,229 @@ +ο»Ώ# Designcenter 2512 +# Journal created by antoi on Thu Dec 18 14:06:36 2025 Eastern Standard Time +# +import math +import NXOpen +import NXOpen.Gateway +def main(args) : + + theSession = NXOpen.Session.GetSession() #type: NXOpen.Session + workPart = theSession.Parts.Work + displayPart = theSession.Parts.Display + # ---------------------------------------------- + # Menu: Edit->Show and Hide->Show and Hide... + # ---------------------------------------------- + markId1 = theSession.SetUndoMark(NXOpen.Session.MarkVisibility.Visible, "Start") + + theSession.SetUndoMarkName(markId1, "Show and Hide Dialog") + + markId2 = theSession.SetUndoMark(NXOpen.Session.MarkVisibility.Visible, "Hide Datums") + + numberHidden1 = theSession.DisplayManager.HideByType("SHOW_HIDE_TYPE_DATUMS", NXOpen.DisplayManager.ShowHideScope.AnyInAssembly) + + nErrs1 = theSession.UpdateManager.DoUpdate(markId2) + + workPart.ModelingViews.WorkView.FitAfterShowOrHide(NXOpen.View.ShowOrHideType.HideOnly) + + markId3 = theSession.SetUndoMark(NXOpen.Session.MarkVisibility.Visible, "Hide Curves") + + numberHidden2 = theSession.DisplayManager.HideByType("SHOW_HIDE_TYPE_CURVES", NXOpen.DisplayManager.ShowHideScope.AnyInAssembly) + + nErrs2 = theSession.UpdateManager.DoUpdate(markId3) + + exists1 = theSession.DoesUndoMarkExist(markId3, "Hide Curves") + + theSession.DeleteUndoMark(markId3, "Hide Curves") + + workPart.ModelingViews.WorkView.FitAfterShowOrHide(NXOpen.View.ShowOrHideType.HideOnly) + + markId4 = theSession.SetUndoMark(NXOpen.Session.MarkVisibility.Visible, "Hide Sketches") + + numberHidden3 = theSession.DisplayManager.HideByType("SHOW_HIDE_TYPE_SKETCHES", NXOpen.DisplayManager.ShowHideScope.AnyInAssembly) + + nErrs3 = theSession.UpdateManager.DoUpdate(markId4) + + workPart.ModelingViews.WorkView.FitAfterShowOrHide(NXOpen.View.ShowOrHideType.HideOnly) + + theSession.SetUndoMarkName(markId1, "Show and Hide") + + theSession.DeleteUndoMark(markId1, None) + + matrix1 = NXOpen.Matrix3x3() + + matrix1.Xx = 0.0 + matrix1.Xy = -1.0 + matrix1.Xz = 0.0 + matrix1.Yx = -1.0 + matrix1.Yy = -0.0 + matrix1.Yz = -0.0 + matrix1.Zx = 0.0 + matrix1.Zy = 0.0 + matrix1.Zz = -1.0 + workPart.ModelingViews.WorkView.Orient(matrix1) + + scaleAboutPoint1 = NXOpen.Point3d(-759.81281858578541, -319.30527689743337, 0.0) + viewCenter1 = NXOpen.Point3d(759.81281858579484, 319.30527689744417, 0.0) + workPart.ModelingViews.WorkView.ZoomAboutPoint(0.80000000000000004, scaleAboutPoint1, viewCenter1) + + scaleAboutPoint2 = NXOpen.Point3d(-949.76602323223278, -399.13159612179305, 0.0) + viewCenter2 = NXOpen.Point3d(949.76602323224245, 399.13159612180385, 0.0) + workPart.ModelingViews.WorkView.ZoomAboutPoint(0.80000000000000004, scaleAboutPoint2, viewCenter2) + + scaleAboutPoint3 = NXOpen.Point3d(-1394.8708922057567, -214.19365760462478, 0.0) + viewCenter3 = NXOpen.Point3d(1394.870892205766, 214.19365760463569, 0.0) + workPart.ModelingViews.WorkView.ZoomAboutPoint(1.25, scaleAboutPoint3, viewCenter3) + + scaleAboutPoint4 = NXOpen.Point3d(-1115.8967137646043, -171.35492608369873, 0.0) + viewCenter4 = NXOpen.Point3d(1115.8967137646139, 171.35492608370959, 0.0) + workPart.ModelingViews.WorkView.ZoomAboutPoint(0.80000000000000004, scaleAboutPoint4, viewCenter4) + + # ---------------------------------------------- + # Menu: File->Export->Image... + # ---------------------------------------------- + markId5 = theSession.SetUndoMark(NXOpen.Session.MarkVisibility.Visible, "Start") + + imageExportBuilder1 = workPart.Views.CreateImageExportBuilder() + + imageExportBuilder1.RegionMode = True + + regiontopleftpoint1 = [None] * 2 + regiontopleftpoint1[0] = 95 + regiontopleftpoint1[1] = 83 + imageExportBuilder1.SetRegionTopLeftPoint(regiontopleftpoint1) + + imageExportBuilder1.RegionWidth = 1157 + + imageExportBuilder1.RegionHeight = 1056 + + imageExportBuilder1.DeviceWidth = 2388 + + imageExportBuilder1.DeviceHeight = 1172 + + imageExportBuilder1.FileFormat = NXOpen.Gateway.ImageExportBuilder.FileFormats.Png + + imageExportBuilder1.FileName = "C:\\Users\\antoi\\Atomizer\\studies\\M1_Mirror\\m1_mirror_cost_reduction_V4\\1_setup\\M1_Blank_Top.png" + + imageExportBuilder1.BackgroundOption = NXOpen.Gateway.ImageExportBuilder.BackgroundOptions.Original + + imageExportBuilder1.EnhanceEdges = False + + nXObject1 = imageExportBuilder1.Commit() + + theSession.DeleteUndoMark(markId5, "Export Image") + + imageExportBuilder1.Destroy() + + markId6 = theSession.SetUndoMark(NXOpen.Session.MarkVisibility.Visible, "Start") + + imageExportBuilder2 = workPart.Views.CreateImageExportBuilder() + + imageExportBuilder2.Destroy() + + theSession.UndoToMark(markId6, None) + + theSession.DeleteUndoMark(markId6, None) + + rotMatrix1 = NXOpen.Matrix3x3() + + rotMatrix1.Xx = -0.34262722569067999 + rotMatrix1.Xy = -0.93944302509010613 + rotMatrix1.Xz = 0.0073066288434778118 + rotMatrix1.Yx = -0.67329035687890959 + rotMatrix1.Yy = 0.24011894541756998 + rotMatrix1.Yz = -0.69930178563008338 + rotMatrix1.Zx = 0.65519972493078527 + rotMatrix1.Zy = -0.2445193134725811 + rotMatrix1.Zz = -0.71478921773451431 + translation1 = NXOpen.Point3d(-691.94814615291523, -16.771832954225655, -903.92900031772103) + workPart.ModelingViews.WorkView.SetRotationTranslationScale(rotMatrix1, translation1, 0.20258147300869808) + + scaleAboutPoint5 = NXOpen.Point3d(-1091.8652302284754, -297.78142642594378, 0.0) + viewCenter5 = NXOpen.Point3d(1091.8652302284847, 297.78142642595469, 0.0) + workPart.ModelingViews.WorkView.ZoomAboutPoint(1.25, scaleAboutPoint5, viewCenter5) + + scaleAboutPoint6 = NXOpen.Point3d(-873.49218418277917, -238.22514114075392, 0.0) + viewCenter6 = NXOpen.Point3d(873.49218418278895, 238.2251411407648, 0.0) + workPart.ModelingViews.WorkView.ZoomAboutPoint(1.25, scaleAboutPoint6, viewCenter6) + + scaleAboutPoint7 = NXOpen.Point3d(-519.08004438038643, -302.5877231331695, 0.0) + viewCenter7 = NXOpen.Point3d(519.08004438039586, 302.58772313318048, 0.0) + workPart.ModelingViews.WorkView.ZoomAboutPoint(0.80000000000000004, scaleAboutPoint7, viewCenter7) + + scaleAboutPoint8 = NXOpen.Point3d(-648.85005547548417, -378.23465391646323, 0.0) + viewCenter8 = NXOpen.Point3d(648.85005547549372, 378.23465391647414, 0.0) + workPart.ModelingViews.WorkView.ZoomAboutPoint(0.80000000000000004, scaleAboutPoint8, viewCenter8) + + scaleAboutPoint9 = NXOpen.Point3d(-726.16874163520447, -271.6602486692816, 0.0) + viewCenter9 = NXOpen.Point3d(726.16874163521379, 271.66024866929223, 0.0) + workPart.ModelingViews.WorkView.ZoomAboutPoint(1.25, scaleAboutPoint9, viewCenter9) + + rotMatrix2 = NXOpen.Matrix3x3() + + rotMatrix2.Xx = -0.35281096074613638 + rotMatrix2.Xy = -0.93549939803135751 + rotMatrix2.Xz = 0.019112882052533756 + rotMatrix2.Yx = -0.67083068516183819 + rotMatrix2.Yy = 0.23864906945399289 + rotMatrix2.Yz = -0.70216295366107118 + rotMatrix2.Zx = 0.65231174895343103 + rotMatrix2.Zy = -0.26055229404422597 + rotMatrix2.Zz = -0.71175970962509794 + translation2 = NXOpen.Point3d(-445.60899304577225, -25.448049758528374, -903.92478002019129) + workPart.ModelingViews.WorkView.SetRotationTranslationScale(rotMatrix2, translation2, 0.25322684126087264) + + rotMatrix3 = NXOpen.Matrix3x3() + + rotMatrix3.Xx = -0.32736574141345925 + rotMatrix3.Xy = -0.94489752125198745 + rotMatrix3.Xz = -0.00058794613984273266 + rotMatrix3.Yx = -0.71924452681462514 + rotMatrix3.Yy = 0.24959027079525001 + rotMatrix3.Yz = -0.64837643955618585 + rotMatrix3.Zx = 0.61279603621108569 + rotMatrix3.Zy = -0.21183335680718612 + rotMatrix3.Zz = -0.76131967460967154 + translation3 = NXOpen.Point3d(-445.6364375527848, -25.373121722553414, -903.99382020435428) + workPart.ModelingViews.WorkView.SetRotationTranslationScale(rotMatrix3, translation3, 0.25322684126087264) + + # ---------------------------------------------- + # Menu: File->Export->Image... + # ---------------------------------------------- + markId7 = theSession.SetUndoMark(NXOpen.Session.MarkVisibility.Visible, "Start") + + imageExportBuilder3 = workPart.Views.CreateImageExportBuilder() + + imageExportBuilder3.RegionMode = True + + regiontopleftpoint2 = [None] * 2 + regiontopleftpoint2[0] = 129 + regiontopleftpoint2[1] = 96 + imageExportBuilder3.SetRegionTopLeftPoint(regiontopleftpoint2) + + imageExportBuilder3.RegionWidth = 1343 + + imageExportBuilder3.RegionHeight = 1045 + + imageExportBuilder3.DeviceWidth = 2388 + + imageExportBuilder3.DeviceHeight = 1172 + + imageExportBuilder3.FileFormat = NXOpen.Gateway.ImageExportBuilder.FileFormats.Png + + imageExportBuilder3.FileName = "C:\\Users\\antoi\\Atomizer\\studies\\M1_Mirror\\m1_mirror_cost_reduction_V4\\1_setup\\M1_Blank_iso.png" + + imageExportBuilder3.BackgroundOption = NXOpen.Gateway.ImageExportBuilder.BackgroundOptions.Original + + imageExportBuilder3.EnhanceEdges = False + + nXObject2 = imageExportBuilder3.Commit() + + theSession.DeleteUndoMark(markId7, "Export Image") + + imageExportBuilder3.Destroy() + + # ---------------------------------------------- + # Menu: Tools->Automation->Journal->Stop Recording + # ---------------------------------------------- + +if __name__ == '__main__': + main(sys.argv[1:]) \ No newline at end of file diff --git a/optimization_engine/insights/design_space.py b/optimization_engine/insights/design_space.py index 1aa7ec64..b080fa77 100644 --- a/optimization_engine/insights/design_space.py +++ b/optimization_engine/insights/design_space.py @@ -51,6 +51,7 @@ class DesignSpaceInsight(StudyInsight): insight_type = "design_space" name = "Design Space Explorer" description = "Interactive parameter-objective relationship visualization" + category = "design_exploration" applicable_to = ["all"] # Works with any optimization study required_files = [] # Requires study.db, not OP2 diff --git a/optimization_engine/insights/modal_analysis.py b/optimization_engine/insights/modal_analysis.py index fc8df4f9..8c0ceeaf 100644 --- a/optimization_engine/insights/modal_analysis.py +++ b/optimization_engine/insights/modal_analysis.py @@ -55,6 +55,7 @@ class ModalInsight(StudyInsight): insight_type = "modal" name = "Modal Analysis" description = "Natural frequencies and mode shapes visualization" + category = "structural_modal" applicable_to = ["modal", "vibration", "dynamic", "all"] required_files = ["*.op2"] diff --git a/optimization_engine/insights/stress_field.py b/optimization_engine/insights/stress_field.py index c3dfd20e..d5a06587 100644 --- a/optimization_engine/insights/stress_field.py +++ b/optimization_engine/insights/stress_field.py @@ -56,6 +56,7 @@ class StressFieldInsight(StudyInsight): insight_type = "stress_field" name = "Stress Distribution" description = "3D stress contour plot with Von Mises and principal stresses" + category = "structural_static" applicable_to = ["structural", "bracket", "beam", "all"] required_files = ["*.op2"] diff --git a/optimization_engine/insights/thermal_field.py b/optimization_engine/insights/thermal_field.py index c18edb2f..138e1a9f 100644 --- a/optimization_engine/insights/thermal_field.py +++ b/optimization_engine/insights/thermal_field.py @@ -55,6 +55,7 @@ class ThermalInsight(StudyInsight): insight_type = "thermal" name = "Thermal Analysis" description = "Temperature distribution and thermal gradients" + category = "thermal" applicable_to = ["thermal", "thermo-structural", "all"] required_files = ["*.op2"] diff --git a/temp_compare.py b/temp_compare.py new file mode 100644 index 00000000..e6cdb0b2 --- /dev/null +++ b/temp_compare.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python +"""Compare V8 and V11 lateral parameter convergence""" +import optuna +import statistics + +# Load V8 study +v8_study = optuna.load_study( + study_name='m1_mirror_cost_reduction_V8', + storage='sqlite:///studies/M1_Mirror/m1_mirror_cost_reduction_V8/3_results/study.db' +) + +# Load V11 study +v11_study = optuna.load_study( + study_name='m1_mirror_cost_reduction_V11', + storage='sqlite:///studies/M1_Mirror/m1_mirror_cost_reduction_V11/3_results/study.db' +) + +print("="*70) +print("V8 BEST TRIAL (Z-only Zernike)") +print("="*70) +v8_best = v8_study.best_trial +print(f"Trial: {v8_best.number}") +print(f"WS: {v8_best.value:.2f}") +print("\nLateral Parameters:") +for k, v in sorted(v8_best.params.items()): + print(f" {k}: {v:.4f}") +print("\nObjectives:") +for k, v in v8_best.user_attrs.items(): + if isinstance(v, (int, float)): + print(f" {k}: {v:.4f}") + +print("\n" + "="*70) +print("V11 BEST TRIAL (ZernikeOPD + extract_relative)") +print("="*70) +v11_best = v11_study.best_trial +print(f"Trial: {v11_best.number}") +print(f"WS: {v11_best.value:.2f}") +print("\nLateral Parameters:") +for k, v in sorted(v11_best.params.items()): + print(f" {k}: {v:.4f}") +print("\nObjectives:") +for k, v in v11_best.user_attrs.items(): + if isinstance(v, (int, float)): + print(f" {k}: {v:.4f}") + +# Compare parameter ranges explored +print("\n" + "="*70) +print("PARAMETER EXPLORATION COMPARISON") +print("="*70) + +params = ['lateral_inner_angle', 'lateral_outer_angle', 'lateral_outer_pivot', + 'lateral_inner_pivot', 'lateral_middle_pivot', 'lateral_closeness'] + +for p in params: + v8_vals = [t.params.get(p) for t in v8_study.trials if t.state.name == 'COMPLETE' and p in t.params] + v11_vals = [t.params.get(p) for t in v11_study.trials if t.state.name == 'COMPLETE' and p in t.params] + + if v8_vals and v11_vals: + print(f"\n{p}:") + print(f" V8: mean={statistics.mean(v8_vals):.2f}, std={statistics.stdev(v8_vals) if len(v8_vals) > 1 else 0:.2f}, range=[{min(v8_vals):.2f}, {max(v8_vals):.2f}]") + print(f" V11: mean={statistics.mean(v11_vals):.2f}, std={statistics.stdev(v11_vals) if len(v11_vals) > 1 else 0:.2f}, range=[{min(v11_vals):.2f}, {max(v11_vals):.2f}]") + print(f" Best V8: {v8_best.params.get(p, 'N/A'):.2f}") + print(f" Best V11: {v11_best.params.get(p, 'N/A'):.2f}") + +# Lateral displacement comparison (V11 has this data) +print("\n" + "="*70) +print("V11 LATERAL DISPLACEMENT DATA (not available in V8)") +print("="*70) +for t in v11_study.trials: + if t.state.name == 'COMPLETE': + lat_rms = t.user_attrs.get('lateral_rms_um', None) + lat_max = t.user_attrs.get('lateral_max_um', None) + if lat_rms is not None: + print(f"Trial {t.number}: RMS={lat_rms:.2f} um, Max={lat_max:.2f} um, WS={t.value:.2f}") diff --git a/tests/audit_v10_fix.py b/tests/audit_v10_fix.py new file mode 100644 index 00000000..0ece99f0 --- /dev/null +++ b/tests/audit_v10_fix.py @@ -0,0 +1,74 @@ +"""Verify the V10 fix - compare Standard extract_relative vs OPD extract_relative.""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from optimization_engine.extractors import ZernikeExtractor, ZernikeOPDExtractor + +op2 = Path('studies/M1_Mirror/m1_mirror_cost_reduction_V10/2_iterations/iter1/assy_m1_assyfem1_sim1-solution_1.op2') + +print("="*70) +print("VERIFICATION: ZernikeOPDExtractor.extract_relative() vs Standard") +print("="*70) +print() + +# Standard extractor +extractor_std = ZernikeExtractor(op2, n_modes=50, filter_orders=4) + +# OPD extractor (with XY lateral correction) +extractor_opd = ZernikeOPDExtractor(op2, n_modes=50, filter_orders=4) + +print("Standard ZernikeExtractor.extract_relative():") +rel_40_std = extractor_std.extract_relative('3', '2') +rel_60_std = extractor_std.extract_relative('4', '2') +rel_90_std = extractor_std.extract_relative('1', '2') +print(f" 40-20: {rel_40_std['relative_filtered_rms_nm']:.2f} nm") +print(f" 60-20: {rel_60_std['relative_filtered_rms_nm']:.2f} nm") +print(f" 90-20 (j1to3): {rel_90_std['relative_rms_filter_j1to3']:.2f} nm") + +print() +print("NEW ZernikeOPDExtractor.extract_relative() (with XY lateral correction):") +rel_40_opd = extractor_opd.extract_relative('3', '2') +rel_60_opd = extractor_opd.extract_relative('4', '2') +rel_90_opd = extractor_opd.extract_relative('1', '2') +print(f" 40-20: {rel_40_opd['relative_filtered_rms_nm']:.2f} nm") +print(f" 60-20: {rel_60_opd['relative_filtered_rms_nm']:.2f} nm") +print(f" 90-20 (j1to3): {rel_90_opd['relative_rms_filter_j1to3']:.2f} nm") + +print() +print("Lateral displacement diagnostics (OPD method):") +print(f" Max lateral: {rel_40_opd['max_lateral_displacement_um']:.3f} um") +print(f" RMS lateral: {rel_40_opd['rms_lateral_displacement_um']:.3f} um") + +print() +print("="*70) +print("COMPARISON") +print("="*70) +print() +print(f"{'Metric':<20} | {'Standard':<12} | {'OPD':<12} | {'Diff %':<10}") +print("-"*60) + +def pct_diff(a, b): + return 100.0 * (b - a) / a if a > 0 else 0 + +print(f"{'40-20 (nm)':<20} | {rel_40_std['relative_filtered_rms_nm']:>12.2f} | {rel_40_opd['relative_filtered_rms_nm']:>12.2f} | {pct_diff(rel_40_std['relative_filtered_rms_nm'], rel_40_opd['relative_filtered_rms_nm']):>+10.1f}%") +print(f"{'60-20 (nm)':<20} | {rel_60_std['relative_filtered_rms_nm']:>12.2f} | {rel_60_opd['relative_filtered_rms_nm']:>12.2f} | {pct_diff(rel_60_std['relative_filtered_rms_nm'], rel_60_opd['relative_filtered_rms_nm']):>+10.1f}%") +print(f"{'90-20 j1to3 (nm)':<20} | {rel_90_std['relative_rms_filter_j1to3']:>12.2f} | {rel_90_opd['relative_rms_filter_j1to3']:>12.2f} | {pct_diff(rel_90_std['relative_rms_filter_j1to3'], rel_90_opd['relative_rms_filter_j1to3']):>+10.1f}%") + +print() +print("="*70) +print("WHAT V9 REPORTED (for comparison)") +print("="*70) +print(" 40-20: 6.10 nm (from DB)") +print(" 60-20: 12.76 nm (from DB)") +print() +print("V10 SHOULD NOW REPORT (using OPD extract_relative):") +print(f" 40-20: {rel_40_opd['relative_filtered_rms_nm']:.2f} nm") +print(f" 60-20: {rel_60_opd['relative_filtered_rms_nm']:.2f} nm") +print(f" 90-20: {rel_90_opd['relative_rms_filter_j1to3']:.2f} nm") +print() +print("V10 OLD WRONG VALUES WERE:") +print(" 40-20: 1.99 nm (WRONG - was computing abs(RMS_target - RMS_ref))") +print(" 60-20: 6.82 nm (WRONG)") +print() +print("FIX VERIFIED: OPD extract_relative() correctly computes RMS of (WFE_target - WFE_ref)") diff --git a/tests/audit_v10_method_diff.py b/tests/audit_v10_method_diff.py new file mode 100644 index 00000000..039b71ef --- /dev/null +++ b/tests/audit_v10_method_diff.py @@ -0,0 +1,72 @@ +"""Compare V9 vs V10 calculation methods.""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from optimization_engine.extractors import ZernikeExtractor + +op2 = Path('studies/M1_Mirror/m1_mirror_cost_reduction_V10/2_iterations/iter1/assy_m1_assyfem1_sim1-solution_1.op2') +extractor = ZernikeExtractor(op2, n_modes=50, filter_orders=4) + +print("="*70) +print("CRITICAL: V9 vs V10 Calculation Method Comparison") +print("="*70) +print() + +# This is what V9 does - computes relative WFE THEN fits Zernike +rel_40 = extractor.extract_relative('3', '2') +rel_60 = extractor.extract_relative('4', '2') +rel_90 = extractor.extract_relative('1', '2') + +print('V9 method (ZernikeExtractor.extract_relative):') +print(' Computes WFE_diff = WFE_target - WFE_ref node-by-node') +print(' Then fits Zernike to WFE_diff') +print() +print(f' 40-20: {rel_40["relative_filtered_rms_nm"]:.2f} nm') +print(f' 60-20: {rel_60["relative_filtered_rms_nm"]:.2f} nm') +print(f' 90-20 (j1to3): {rel_90["relative_rms_filter_j1to3"]:.2f} nm') + +# Individual absolute values +r20 = extractor.extract_subcase('2') +r40 = extractor.extract_subcase('3') +r60 = extractor.extract_subcase('4') +r90 = extractor.extract_subcase('1') + +print() +print('='*70) +print('Individual absolute RMS values:') +print('='*70) +print(f' 20 deg: {r20["filtered_rms_nm"]:.2f} nm') +print(f' 40 deg: {r40["filtered_rms_nm"]:.2f} nm') +print(f' 60 deg: {r60["filtered_rms_nm"]:.2f} nm') +print(f' 90 deg: {r90["filtered_rms_nm"]:.2f} nm') + +print() +print('='*70) +print('V10 method (WRONG - difference of RMS values):') +print(' Computes RMS_target - RMS_ref') +print(' This is NOT the same as RMS of the difference!') +print('='*70) +print() +print(f' 40-20: {r40["filtered_rms_nm"] - r20["filtered_rms_nm"]:.2f} nm') +print(f' 60-20: {r60["filtered_rms_nm"] - r20["filtered_rms_nm"]:.2f} nm') +print(f' After abs(): {abs(r40["filtered_rms_nm"] - r20["filtered_rms_nm"]):.2f} nm') +print(f' After abs(): {abs(r60["filtered_rms_nm"] - r20["filtered_rms_nm"]):.2f} nm') + +print() +print('='*70) +print('CONCLUSION') +print('='*70) +print() +print('V10 BUG: Computes abs(RMS_target - RMS_ref) instead of RMS(WFE_target - WFE_ref)') +print() +print('The CORRECT relative WFE (from V9 method):') +print(f' 40-20: {rel_40["relative_filtered_rms_nm"]:.2f} nm') +print(f' 60-20: {rel_60["relative_filtered_rms_nm"]:.2f} nm') +print(f' 90-20: {rel_90["relative_rms_filter_j1to3"]:.2f} nm') +print() +print('The WRONG values V10 reports:') +print(f' 40-20: {abs(r40["filtered_rms_nm"] - r20["filtered_rms_nm"]):.2f} nm') +print(f' 60-20: {abs(r60["filtered_rms_nm"] - r20["filtered_rms_nm"]):.2f} nm') +print() +print('V10 values are ~3-4x LOWER than correct values!') diff --git a/tests/audit_v10_wfe.py b/tests/audit_v10_wfe.py new file mode 100644 index 00000000..eb37daaa --- /dev/null +++ b/tests/audit_v10_wfe.py @@ -0,0 +1,143 @@ +"""Audit V10 WFE values - independent verification.""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from optimization_engine.extractors import ZernikeOPDExtractor, ZernikeExtractor + +print('='*70) +print('AUDIT: V10 WFE Values - Independent Verification') +print('='*70) + +# V10 iter1 (baseline trial) +op2_v10 = Path('studies/M1_Mirror/m1_mirror_cost_reduction_V10/2_iterations/iter1/assy_m1_assyfem1_sim1-solution_1.op2') + +if not op2_v10.exists(): + print('V10 OP2 file not found!') + sys.exit(1) + +print(f'OP2 file: {op2_v10}') +print(f'Size: {op2_v10.stat().st_size / 1024 / 1024:.1f} MB') + +# Test with ZernikeOPDExtractor (what V10 uses) +print() +print('='*70) +print('Method 1: ZernikeOPDExtractor (what V10 uses)') +print('='*70) + +extractor_opd = ZernikeOPDExtractor(op2_v10, n_modes=50, filter_orders=4) + +result_20_opd = extractor_opd.extract_subcase('2') # Reference +result_40_opd = extractor_opd.extract_subcase('3') # 40 deg +result_60_opd = extractor_opd.extract_subcase('4') # 60 deg +result_90_opd = extractor_opd.extract_subcase('1') # 90 deg MFG + +print() +print('ABSOLUTE values (ZernikeOPD):') +print(f' 20 deg: filtered_rms = {result_20_opd["filtered_rms_nm"]:.2f} nm') +print(f' 40 deg: filtered_rms = {result_40_opd["filtered_rms_nm"]:.2f} nm') +print(f' 60 deg: filtered_rms = {result_60_opd["filtered_rms_nm"]:.2f} nm') +print(f' 90 deg: filtered_rms = {result_90_opd["filtered_rms_nm"]:.2f} nm') + +print() +print('RELATIVE values (target - ref) as V10 computes:') +rel_40_opd = result_40_opd['filtered_rms_nm'] - result_20_opd['filtered_rms_nm'] +rel_60_opd = result_60_opd['filtered_rms_nm'] - result_20_opd['filtered_rms_nm'] +rel_mfg_opd = result_90_opd['rms_filter_j1to3_nm'] - result_20_opd['rms_filter_j1to3_nm'] +print(f' 40-20: {rel_40_opd:.2f} nm (abs: {abs(rel_40_opd):.2f})') +print(f' 60-20: {rel_60_opd:.2f} nm (abs: {abs(rel_60_opd):.2f})') +print(f' 90-20 (j1to3): {rel_mfg_opd:.2f} nm (abs: {abs(rel_mfg_opd):.2f})') + +print() +print('V10 uses abs() -> stores:') +print(f' rel_filtered_rms_40_vs_20: {abs(rel_40_opd):.2f}') +print(f' rel_filtered_rms_60_vs_20: {abs(rel_60_opd):.2f}') +print(f' mfg_90_optician_workload: {abs(rel_mfg_opd):.2f}') + +# Test with Standard ZernikeExtractor (what V9 uses) +print() +print('='*70) +print('Method 2: Standard ZernikeExtractor (what V9 likely uses)') +print('='*70) + +# Find the BDF file +bdf_files = list(op2_v10.parent.glob('*.dat')) +bdf_path = bdf_files[0] if bdf_files else None +print(f'BDF file: {bdf_path}') + +extractor_std = ZernikeExtractor(op2_v10, bdf_path=bdf_path, n_modes=50, filter_orders=4) + +result_20_std = extractor_std.extract_subcase('2') +result_40_std = extractor_std.extract_subcase('3') +result_60_std = extractor_std.extract_subcase('4') +result_90_std = extractor_std.extract_subcase('1') + +print() +print('ABSOLUTE values (Standard Z-only):') +print(f' 20 deg: filtered_rms = {result_20_std["filtered_rms_nm"]:.2f} nm') +print(f' 40 deg: filtered_rms = {result_40_std["filtered_rms_nm"]:.2f} nm') +print(f' 60 deg: filtered_rms = {result_60_std["filtered_rms_nm"]:.2f} nm') +print(f' 90 deg: filtered_rms = {result_90_std["filtered_rms_nm"]:.2f} nm') + +print() +print('RELATIVE values (Standard):') +rel_40_std = result_40_std['filtered_rms_nm'] - result_20_std['filtered_rms_nm'] +rel_60_std = result_60_std['filtered_rms_nm'] - result_20_std['filtered_rms_nm'] +print(f' 40-20: {rel_40_std:.2f} nm (abs: {abs(rel_40_std):.2f})') +print(f' 60-20: {rel_60_std:.2f} nm (abs: {abs(rel_60_std):.2f})') + +# Compare +print() +print('='*70) +print('COMPARISON: OPD vs Standard') +print('='*70) +print() +print(f'40-20: OPD={abs(rel_40_opd):.2f} nm vs Standard={abs(rel_40_std):.2f} nm') +print(f'60-20: OPD={abs(rel_60_opd):.2f} nm vs Standard={abs(rel_60_std):.2f} nm') + +print() +print('Lateral displacement (OPD method):') +print(f' Max: {result_40_opd.get("max_lateral_displacement_um", 0):.3f} um') +print(f' RMS: {result_40_opd.get("rms_lateral_displacement_um", 0):.3f} um') + +# Now check what V9 reports +print() +print('='*70) +print('V9 COMPARISON (iter12 from best archive)') +print('='*70) + +op2_v9 = Path('studies/M1_Mirror/m1_mirror_cost_reduction_V9/2_iterations/iter12/assy_m1_assyfem1_sim1-solution_1.op2') +if op2_v9.exists(): + extractor_v9_opd = ZernikeOPDExtractor(op2_v9, n_modes=50, filter_orders=4) + extractor_v9_std = ZernikeExtractor(op2_v9, n_modes=50, filter_orders=4) + + r20_v9_opd = extractor_v9_opd.extract_subcase('2') + r40_v9_opd = extractor_v9_opd.extract_subcase('3') + r60_v9_opd = extractor_v9_opd.extract_subcase('4') + + r20_v9_std = extractor_v9_std.extract_subcase('2') + r40_v9_std = extractor_v9_std.extract_subcase('3') + r60_v9_std = extractor_v9_std.extract_subcase('4') + + rel_40_v9_opd = abs(r40_v9_opd['filtered_rms_nm'] - r20_v9_opd['filtered_rms_nm']) + rel_60_v9_opd = abs(r60_v9_opd['filtered_rms_nm'] - r20_v9_opd['filtered_rms_nm']) + rel_40_v9_std = abs(r40_v9_std['filtered_rms_nm'] - r20_v9_std['filtered_rms_nm']) + rel_60_v9_std = abs(r60_v9_std['filtered_rms_nm'] - r20_v9_std['filtered_rms_nm']) + + print() + print('V9 iter12 relative values:') + print(f' 40-20: OPD={rel_40_v9_opd:.2f} nm vs Standard={rel_40_v9_std:.2f} nm') + print(f' 60-20: OPD={rel_60_v9_opd:.2f} nm vs Standard={rel_60_v9_std:.2f} nm') +else: + print('V9 OP2 not found') + +print() +print('='*70) +print('SUMMARY') +print('='*70) +print() +print('V10 reports: 40-20=1.99nm, 60-20=6.82nm (using OPD method)') +print('V9 reports: 40-20=6.10nm, 60-20=12.76nm (likely Standard method)') +print() +print('If both studies have SIMILAR geometry, the OPD method should NOT') +print('give such dramatically different values. This needs investigation.') diff --git a/tests/check_api_routes.py b/tests/check_api_routes.py new file mode 100644 index 00000000..9e2083e3 --- /dev/null +++ b/tests/check_api_routes.py @@ -0,0 +1,20 @@ +"""Check API routes from running backend.""" +import requests +import json + +# Get OpenAPI spec +resp = requests.get("http://localhost:8000/openapi.json", timeout=10) +spec = resp.json() + +# Find insight routes +print("Insight-related routes:") +print("=" * 60) +for path in sorted(spec.get("paths", {}).keys()): + if "insight" in path.lower(): + print(f" {path}") + +print() +print("All routes:") +print("-" * 60) +for path in sorted(spec.get("paths", {}).keys()): + print(f" {path}") diff --git a/tests/debug_figure_coords.py b/tests/debug_figure_coords.py new file mode 100644 index 00000000..ec1fa5f1 --- /dev/null +++ b/tests/debug_figure_coords.py @@ -0,0 +1,83 @@ +"""Debug script to compare figure.dat vs BDF node coordinates.""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import numpy as np +import logging +logging.disable(logging.WARNING) + +study_dir = Path(r"c:\Users\antoi\Atomizer\studies\M1_Mirror\m1_mirror_cost_reduction_V9") + +# Load figure.dat +from optimization_engine.extractors.extract_zernike_figure import load_figure_geometry +fig_geo = load_figure_geometry(study_dir / "1_setup/model/figure.dat") +fig_nids = set(fig_geo.keys()) + +# Find OP2 and BDF +op2_file = list(study_dir.glob("3_results/best_design_archive/**/*.op2"))[0] +bdf_file = op2_file.with_suffix(".dat") + +# Load BDF +from pyNastran.bdf.bdf import BDF +bdf = BDF(log=None, debug=False) +bdf.read_bdf(str(bdf_file)) +bdf_nids = set(bdf.nodes.keys()) + +# Load OP2 +from pyNastran.op2.op2 import OP2 +op2 = OP2(log=None, debug=False) +op2.read_op2(str(op2_file)) +disps = op2.displacements +first_key = list(disps.keys())[0] +op2_nids = set(int(n) for n in disps[first_key].node_gridtype[:,0]) + +print(f"Figure.dat nodes: {len(fig_nids)}") +print(f"BDF nodes: {len(bdf_nids)}") +print(f"OP2 nodes: {len(op2_nids)}") + +print() +print(f"Figure ^ BDF: {len(fig_nids & bdf_nids)}") +print(f"Figure ^ OP2: {len(fig_nids & op2_nids)}") +print(f"BDF ^ OP2: {len(bdf_nids & op2_nids)}") + +# Sample coords - use a node in all three +common_nids = list(fig_nids & bdf_nids & op2_nids)[:5] +print() +print("Sample common node coords comparison:") +z_diffs = [] +for nid in common_nids: + fig_pos = fig_geo[nid] + bdf_pos = bdf.nodes[nid].get_position() + diff = np.array(fig_pos) - bdf_pos + z_diffs.append(diff[2]) + print(f" Node {nid}:") + print(f" Figure: ({fig_pos[0]:.6f}, {fig_pos[1]:.6f}, {fig_pos[2]:.9f})") + print(f" BDF: ({bdf_pos[0]:.6f}, {bdf_pos[1]:.6f}, {bdf_pos[2]:.9f})") + print(f" Z diff: {diff[2]*1e6:.3f} nm") + +# Statistics on all matching nodes +all_common = fig_nids & bdf_nids +all_z_diffs = [] +all_xy_diffs = [] +for nid in all_common: + fig_pos = np.array(fig_geo[nid]) + bdf_pos = bdf.nodes[nid].get_position() + diff = fig_pos - bdf_pos + all_z_diffs.append(diff[2]) + all_xy_diffs.append(np.sqrt(diff[0]**2 + diff[1]**2)) + +all_z_diffs = np.array(all_z_diffs) +all_xy_diffs = np.array(all_xy_diffs) + +print() +print(f"=== ALL {len(all_common)} COMMON NODES ===") +print(f"Z difference (figure - BDF):") +print(f" Min: {all_z_diffs.min()*1e6:.3f} nm") +print(f" Max: {all_z_diffs.max()*1e6:.3f} nm") +print(f" Mean: {all_z_diffs.mean()*1e6:.3f} nm") +print(f" RMS: {np.sqrt(np.mean(all_z_diffs**2))*1e6:.3f} nm") +print() +print(f"XY difference (figure - BDF):") +print(f" Max: {all_xy_diffs.max()*1e3:.6f} um") +print(f" RMS: {np.sqrt(np.mean(all_xy_diffs**2))*1e3:.6f} um") diff --git a/tests/debug_insights.py b/tests/debug_insights.py new file mode 100644 index 00000000..c738b2c6 --- /dev/null +++ b/tests/debug_insights.py @@ -0,0 +1,50 @@ +"""Debug insights availability for a study.""" +import sys +sys.path.insert(0, ".") +from pathlib import Path + +# Test study path resolution +study_id = 'm1_mirror_cost_reduction_V9' +STUDIES_DIR = Path('studies') + +# Check nested path +for topic_dir in STUDIES_DIR.iterdir(): + if topic_dir.is_dir(): + study_dir = topic_dir / study_id + if study_dir.exists(): + print(f"Found study at: {study_dir}") + print(f"Has 1_setup: {(study_dir / '1_setup').exists()}") + print(f"Has 2_results: {(study_dir / '2_results').exists()}") + + # Check what insights are available + from optimization_engine.insights import list_available_insights, get_configured_insights, recommend_insights_for_study + + print("\n--- Available insights (can_generate=True) ---") + available = list_available_insights(study_dir) + print(f"Count: {len(available)}") + for a in available: + print(f" - {a}") + + print("\n--- Configured insights ---") + configured = get_configured_insights(study_dir) + print(f"Count: {len(configured)}") + for c in configured: + print(f" - {c.type}: {c.name}") + + print("\n--- Recommendations ---") + recs = recommend_insights_for_study(study_dir) + print(f"Count: {len(recs)}") + for r in recs: + print(f" - {r['type']}: {r['name']}") + + # Test individual insight can_generate + print("\n--- Testing each insight's can_generate ---") + from optimization_engine.insights import get_insight, list_insights + + for info in list_insights(): + insight = get_insight(info['type'], study_dir) + if insight: + can = insight.can_generate() + print(f" {info['type']:20} can_generate={can}") + + break diff --git a/tests/test_insights_import.py b/tests/test_insights_import.py new file mode 100644 index 00000000..5f9618bf --- /dev/null +++ b/tests/test_insights_import.py @@ -0,0 +1,21 @@ +"""Test if insights can be imported from backend context.""" +import sys +from pathlib import Path + +# Replicate the path setup from main.py +backend_path = Path(__file__).parent.parent / "atomizer-dashboard" / "backend" / "api" +sys.path.insert(0, str(backend_path.parent.parent.parent.parent)) +sys.path.insert(0, str(backend_path.parent)) + +print(f"sys.path[0]: {sys.path[0]}") +print(f"sys.path[1]: {sys.path[1]}") + +try: + from api.routes import insights + print(f"insights module imported: {insights}") + print(f"insights.router: {insights.router}") + print(f"routes: {[r.path for r in insights.router.routes]}") +except Exception as e: + print(f"ERROR importing insights: {e}") + import traceback + traceback.print_exc() diff --git a/tools/test_zernike_import.py b/tests/test_zernike_import.py similarity index 100% rename from tools/test_zernike_import.py rename to tests/test_zernike_import.py diff --git a/tests/test_zernike_insight.py b/tests/test_zernike_insight.py new file mode 100644 index 00000000..fd9b26a6 --- /dev/null +++ b/tests/test_zernike_insight.py @@ -0,0 +1,31 @@ +"""Test script for Zernike WFE insight with OPD method.""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from optimization_engine.insights.zernike_wfe import ZernikeWFEInsight +from optimization_engine.insights.base import InsightConfig + +study = Path('studies/M1_Mirror/m1_mirror_cost_reduction_V9') +insight = ZernikeWFEInsight(study) + +if insight.can_generate(): + print('Insight can be generated!') + print(f'OP2: {insight.op2_path}') + print(f'Geo: {insight.geo_path}') + config = InsightConfig() + result = insight.generate(config) + if result.success: + n_files = len(result.summary.get('html_files', [])) + print(f'Success! Generated {n_files} files') + for f in result.summary.get('html_files', []): + print(f' - {Path(f).name}') + print() + print('Summary:') + for k, v in result.summary.items(): + if k != 'html_files': + print(f' {k}: {v}') + else: + print(f'Failed: {result.error}') +else: + print('Cannot generate insight') diff --git a/tests/test_zernike_opd_comparison.py b/tests/test_zernike_opd_comparison.py new file mode 100644 index 00000000..e2532433 --- /dev/null +++ b/tests/test_zernike_opd_comparison.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python +""" +Quick test script to compare Standard vs OPD Zernike methods. + +Usage: + conda activate atomizer + python test_zernike_opd_comparison.py + +This will analyze a recent OP2 file and show you: +1. How much lateral displacement exists +2. How different the WFE metrics are between methods +3. Whether you need to switch to OPD method for your optimizations +""" + +from pathlib import Path +import sys + +# Add project root to path +sys.path.insert(0, str(Path(__file__).parent)) + + +def main(): + import numpy as np + from optimization_engine.extractors.extract_zernike_opd import ( + ZernikeOPDExtractor, + ) + + # Find a recent OP2 file from your studies + studies_path = Path("studies/M1_Mirror") + + op2_files = list(studies_path.glob("**/2_iterations/**/*.op2")) + if not op2_files: + op2_files = list(studies_path.glob("**/*.op2")) + + if not op2_files: + print("No OP2 files found in studies/M1_Mirror") + return + + # Use the most recent one + op2_file = max(op2_files, key=lambda p: p.stat().st_mtime) + print(f"Analyzing: {op2_file}") + print("=" * 80) + + # Run comparison + try: + extractor = ZernikeOPDExtractor(op2_file) + + print(f"\nAvailable subcases: {list(extractor.displacements.keys())}") + + # Show geometry info + geo = extractor.node_geometry + all_pos = np.array(list(geo.values())) + print(f"\n--- Geometry Info ---") + print(f" Nodes: {len(geo)}") + print(f" X range: {all_pos[:,0].min():.1f} to {all_pos[:,0].max():.1f} mm") + print(f" Y range: {all_pos[:,1].min():.1f} to {all_pos[:,1].max():.1f} mm") + print(f" Z range: {all_pos[:,2].min():.1f} to {all_pos[:,2].max():.1f} mm") + + for label in extractor.displacements.keys(): + print(f"\n{'=' * 80}") + print(f"SUBCASE {label}") + print('=' * 80) + + comparison = extractor.extract_comparison(label) + + print(f"\n--- Standard Method (Z-only) ---") + print(f" Global RMS: {comparison['standard_method']['global_rms_nm']:.2f} nm") + print(f" Filtered RMS: {comparison['standard_method']['filtered_rms_nm']:.2f} nm") + + print(f"\n--- Rigorous OPD Method ---") + print(f" Global RMS: {comparison['opd_method']['global_rms_nm']:.2f} nm") + print(f" Filtered RMS: {comparison['opd_method']['filtered_rms_nm']:.2f} nm") + + print(f"\n--- Difference (OPD - Standard) ---") + delta = comparison['delta']['filtered_rms_nm'] + pct = comparison['delta']['percent_difference_filtered'] + sign = "+" if delta > 0 else "" + print(f" Filtered RMS: {sign}{delta:.2f} nm ({sign}{pct:.1f}%)") + + print(f"\n--- Lateral Displacement ---") + print(f" Max: {comparison['lateral_displacement']['max_um']:.3f} Β΅m") + print(f" RMS: {comparison['lateral_displacement']['rms_um']:.3f} Β΅m") + print(f" P99: {comparison['lateral_displacement']['p99_um']:.3f} Β΅m") + + print(f"\n>>> {comparison['recommendation']}") + + except Exception as e: + print(f"Error: {e}") + import traceback + traceback.print_exc() + return + + +if __name__ == '__main__': + main() diff --git a/tests/test_zernike_opd_with_prescription.py b/tests/test_zernike_opd_with_prescription.py new file mode 100644 index 00000000..31da3041 --- /dev/null +++ b/tests/test_zernike_opd_with_prescription.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python +""" +Test ZernikeOPDExtractor with validated M1 Mirror optical prescription. + +Compares: +1. Standard Zernike (Z-displacement only at original x,y) +2. OPD Zernike with auto-estimated focal length +3. OPD Zernike with correct focal length (1445 mm from prescription) + +M1 Mirror Optical Prescription: +- Radius of Curvature: 2890 Β± 3 mm +- Conic Constant: -0.987 Β± 0.001 (near-parabolic) +- Clear Aperture: 1202 mm +- Central Bore: 271.56 mm +- Focal Length: 1445 mm (R/2) +""" + +from pathlib import Path +import sys + +# Add project root to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import numpy as np + + +def run_comparison(op2_path: Path): + """Run comparison between standard and OPD Zernike methods.""" + from optimization_engine.extractors.extract_zernike_opd import ZernikeOPDExtractor + from optimization_engine.extractors.extract_zernike_wfe import ZernikeExtractor + + print("=" * 70) + print("ZERNIKE METHOD COMPARISON WITH VALIDATED PRESCRIPTION") + print("=" * 70) + print(f"\nOP2 file: {op2_path.name}") + print(f"Optical prescription focal length: 1445 mm") + print() + + # 1. Standard Zernike (Z-displacement only) + print("1. STANDARD ZERNIKE (Z-displacement at original x,y)") + print("-" * 50) + try: + std_extractor = ZernikeExtractor(op2_path) + std_results = std_extractor.extract_all_subcases() + + for sc, data in std_results.items(): + coeffs = data['coefficients'] + rms = data['rms_wfe_nm'] + print(f" Subcase {sc}: RMS WFE = {rms:.2f} nm") + except Exception as e: + print(f" Error: {e}") + std_results = None + print() + + # 2. OPD Zernike with auto-estimated focal length + print("2. OPD ZERNIKE (auto-estimated focal length)") + print("-" * 50) + try: + opd_auto = ZernikeOPDExtractor(op2_path, concave=True) + auto_focal = opd_auto.focal_length + print(f" Auto-estimated focal length: {auto_focal:.1f} mm") + + opd_auto_results = opd_auto.extract_all_subcases() + for sc, data in opd_auto_results.items(): + rms = data['rms_wfe_nm'] + lat = data.get('max_lateral_displacement_um', 0) + print(f" Subcase {sc}: RMS WFE = {rms:.2f} nm, Max lateral = {lat:.2f} Β΅m") + except Exception as e: + print(f" Error: {e}") + opd_auto_results = None + print() + + # 3. OPD Zernike with correct prescription focal length + print("3. OPD ZERNIKE (prescription focal length = 1445 mm)") + print("-" * 50) + try: + opd_correct = ZernikeOPDExtractor(op2_path, focal_length=1445.0, concave=True) + print(f" Using focal length: {opd_correct.focal_length:.1f} mm") + + opd_correct_results = opd_correct.extract_all_subcases() + for sc, data in opd_correct_results.items(): + rms = data['rms_wfe_nm'] + lat = data.get('max_lateral_displacement_um', 0) + print(f" Subcase {sc}: RMS WFE = {rms:.2f} nm, Max lateral = {lat:.2f} Β΅m") + except Exception as e: + print(f" Error: {e}") + opd_correct_results = None + print() + + # 4. Comparison summary + if std_results and opd_correct_results: + print("=" * 70) + print("COMPARISON SUMMARY") + print("=" * 70) + print() + print(f"{'Subcase':<10} {'Standard':<15} {'OPD (auto)':<15} {'OPD (1445mm)':<15} {'Diff %':<10}") + print("-" * 65) + + for sc in std_results.keys(): + std_rms = std_results[sc]['rms_wfe_nm'] + auto_rms = opd_auto_results[sc]['rms_wfe_nm'] if opd_auto_results else 0 + corr_rms = opd_correct_results[sc]['rms_wfe_nm'] + diff_pct = ((corr_rms - std_rms) / std_rms * 100) if std_rms > 0 else 0 + + print(f"{sc:<10} {std_rms:<15.2f} {auto_rms:<15.2f} {corr_rms:<15.2f} {diff_pct:>+8.1f}%") + + print() + print("LATERAL DISPLACEMENT ANALYSIS") + print("-" * 50) + + for sc, data in opd_correct_results.items(): + lat = data.get('max_lateral_displacement_um', 0) + severity = "CRITICAL - OPD method required" if lat > 10 else "Low - standard OK" if lat < 1 else "Moderate" + print(f" Subcase {sc}: Max lateral = {lat:.2f} Β΅m ({severity})") + + print() + + # Tracking WFE comparison (40-20 and 60-20) + if 2 in opd_correct_results and 3 in opd_correct_results and 4 in opd_correct_results: + print("TRACKING WFE (differential between elevations)") + print("-" * 50) + + # Get coefficients for differential analysis + z20 = np.array(opd_correct_results[2]['coefficients']) + z40 = np.array(opd_correct_results[3]['coefficients']) + z60 = np.array(opd_correct_results[4]['coefficients']) + + # Differential (remove J1-J4: piston, tip, tilt, defocus) + diff_40_20 = z40 - z20 + diff_60_20 = z60 - z20 + + # RMS of filtered differential (J5+) + rms_40_20 = np.sqrt(np.sum(diff_40_20[4:]**2)) # Skip J1-J4 + rms_60_20 = np.sqrt(np.sum(diff_60_20[4:]**2)) + + print(f" 40Β°-20Β° tracking WFE: {rms_40_20:.2f} nm RMS (filtered)") + print(f" 60Β°-20Β° tracking WFE: {rms_60_20:.2f} nm RMS (filtered)") + + print() + print(" Standard method comparison:") + z20_std = np.array(std_results[2]['coefficients']) + z40_std = np.array(std_results[3]['coefficients']) + z60_std = np.array(std_results[4]['coefficients']) + + diff_40_20_std = z40_std - z20_std + diff_60_20_std = z60_std - z20_std + + rms_40_20_std = np.sqrt(np.sum(diff_40_20_std[4:]**2)) + rms_60_20_std = np.sqrt(np.sum(diff_60_20_std[4:]**2)) + + print(f" 40Β°-20Β° tracking WFE (std): {rms_40_20_std:.2f} nm RMS") + print(f" 60Β°-20Β° tracking WFE (std): {rms_60_20_std:.2f} nm RMS") + + print() + print(f" Difference (OPD vs Standard):") + print(f" 40Β°-20Β°: {rms_40_20 - rms_40_20_std:+.2f} nm ({(rms_40_20/rms_40_20_std - 1)*100:+.1f}%)") + print(f" 60Β°-20Β°: {rms_60_20 - rms_60_20_std:+.2f} nm ({(rms_60_20/rms_60_20_std - 1)*100:+.1f}%)") + + print() + print("=" * 70) + + +def main(): + import argparse + + parser = argparse.ArgumentParser(description='Test ZernikeOPD with M1 prescription') + parser.add_argument('path', nargs='?', default='.', + help='Path to OP2 file or study directory') + + args = parser.parse_args() + path = Path(args.path).resolve() + + # Find OP2 file + if path.is_file() and path.suffix.lower() == '.op2': + op2_path = path + elif path.is_dir(): + # Look for best design or recent iteration + patterns = [ + '3_results/best_design_archive/**/*.op2', + '2_iterations/iter1/*.op2', + '**/*.op2' + ] + for pattern in patterns: + files = list(path.glob(pattern)) + if files: + op2_path = max(files, key=lambda p: p.stat().st_mtime) + break + else: + print(f"No OP2 file found in {path}") + sys.exit(1) + else: + print(f"Invalid path: {path}") + sys.exit(1) + + run_comparison(op2_path) + + +if __name__ == '__main__': + main() diff --git a/tools/create_pareto_graphs.py b/tools/create_pareto_graphs.py new file mode 100644 index 00000000..f81a38d2 --- /dev/null +++ b/tools/create_pareto_graphs.py @@ -0,0 +1,388 @@ +""" +Create Pareto front visualizations for M1 Mirror optimization data. +Shows relationship between geometric parameters and 60/20 WFE performance. +""" + +import pandas as pd +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.colors import LinearSegmentedColormap +import matplotlib.patches as mpatches + +# Set style for publication-quality plots +plt.rcParams.update({ + 'font.family': 'sans-serif', + 'font.sans-serif': ['Arial', 'Helvetica', 'DejaVu Sans'], + 'font.size': 11, + 'axes.titlesize': 16, + 'axes.labelsize': 13, + 'xtick.labelsize': 11, + 'ytick.labelsize': 11, + 'legend.fontsize': 10, + 'figure.dpi': 150, + 'savefig.dpi': 150, + 'axes.spines.top': False, + 'axes.spines.right': False, +}) + +# Load data +df = pd.read_csv(r'c:\Users\antoi\Atomizer\studies\m1_mirror_all_trials_export.csv') + +print("=== Data Overview ===") +print(f"Total rows: {len(df)}") +print(f"\nColumn data availability:") +for col in df.columns: + non_null = df[col].notna().sum() + if non_null > 0: + print(f" {col}: {non_null} ({100*non_null/len(df):.1f}%)") + +print(f"\nStudies: {df['study'].unique()}") + +# Filter for rows with the key parameters +thickness_col = 'center_thickness' +angle_col = 'blank_backface_angle' +wfe_col = 'rel_filtered_rms_60_vs_20' + +print(f"\n=== Key columns ===") +print(f"center_thickness non-null: {df[thickness_col].notna().sum()}") +print(f"blank_backface_angle non-null: {df[angle_col].notna().sum()}") +print(f"rel_filtered_rms_60_vs_20 non-null: {df[wfe_col].notna().sum()}") + +# Create filtered dataset with valid WFE values +df_valid = df[df[wfe_col].notna()].copy() +print(f"\nRows with valid WFE data (before outlier removal): {len(df_valid)}") + +if len(df_valid) == 0: + print("No valid WFE data found!") + exit() + +# Remove outliers - WFE values above 1000 are clearly failed simulations +WFE_THRESHOLD = 100 # Reasonable upper bound for WFE ratio +df_valid = df_valid[df_valid[wfe_col] < WFE_THRESHOLD].copy() +print(f"Rows with valid WFE data (after outlier removal, WFE < {WFE_THRESHOLD}): {len(df_valid)}") + +# Show ranges +print(f"\n=== Value ranges (clean data) ===") +if df_valid[thickness_col].notna().any(): + print(f"center_thickness: {df_valid[thickness_col].min():.2f} - {df_valid[thickness_col].max():.2f} mm") +if df_valid[angle_col].notna().any(): + print(f"blank_backface_angle: {df_valid[angle_col].min():.2f} - {df_valid[angle_col].max():.2f}Β°") +print(f"rel_filtered_rms_60_vs_20: {df_valid[wfe_col].min():.4f} - {df_valid[wfe_col].max():.4f}") + +# Also check mass +if 'mass_kg' in df_valid.columns and df_valid['mass_kg'].notna().any(): + print(f"mass_kg: {df_valid['mass_kg'].min():.2f} - {df_valid['mass_kg'].max():.2f} kg") + + +def compute_pareto_front(x, y, minimize_x=True, minimize_y=True): + """ + Compute Pareto front indices. + Returns indices of points on the Pareto front. + """ + # Create array of points + points = np.column_stack([x, y]) + n_points = len(points) + + # Adjust for minimization/maximization + if not minimize_x: + points[:, 0] = -points[:, 0] + if not minimize_y: + points[:, 1] = -points[:, 1] + + # Find Pareto front + pareto_mask = np.ones(n_points, dtype=bool) + + for i in range(n_points): + if pareto_mask[i]: + # Check if any other point dominates point i + for j in range(n_points): + if i != j and pareto_mask[j]: + # j dominates i if j is <= in all objectives and < in at least one + if (points[j, 0] <= points[i, 0] and points[j, 1] <= points[i, 1] and + (points[j, 0] < points[i, 0] or points[j, 1] < points[i, 1])): + pareto_mask[i] = False + break + + return np.where(pareto_mask)[0] + + +def create_pareto_plot(df_plot, x_col, y_col, x_label, y_label, title, filename, + minimize_x=True, minimize_y=True, color_by=None, color_label=None): + """Create a publication-quality Pareto front plot.""" + + # Filter valid data + mask = df_plot[x_col].notna() & df_plot[y_col].notna() + df_clean = df_plot[mask].copy() + + if len(df_clean) < 2: + print(f"Not enough data for {title}") + return + + x = df_clean[x_col].values + y = df_clean[y_col].values + + # Compute Pareto front + pareto_idx = compute_pareto_front(x, y, minimize_x, minimize_y) + + # Sort Pareto points by x for line drawing + pareto_points = np.column_stack([x[pareto_idx], y[pareto_idx]]) + sort_idx = np.argsort(pareto_points[:, 0]) + pareto_sorted = pareto_points[sort_idx] + + # Create figure with professional styling + fig, ax = plt.subplots(figsize=(12, 8)) + fig.patch.set_facecolor('white') + + # Professional color palette + bg_color = '#f8f9fa' + grid_color = '#dee2e6' + point_color = '#6c757d' + pareto_color = '#dc3545' + pareto_fill = '#ffc107' + + ax.set_facecolor(bg_color) + + # Color scheme - use mass as color if available + if color_by is not None and color_by in df_clean.columns and df_clean[color_by].notna().sum() > 10: + # Only use color if we have enough colored points + color_mask = df_clean[color_by].notna() + colors = df_clean.loc[color_mask, color_by].values + + # Plot non-colored points in gray + ax.scatter(x[~color_mask.values], y[~color_mask.values], + c=point_color, alpha=0.3, s=40, + edgecolors='white', linewidth=0.3, zorder=2) + + # Plot colored points + scatter = ax.scatter(x[color_mask.values], y[color_mask.values], + c=colors, cmap='plasma', alpha=0.7, s=60, + edgecolors='white', linewidth=0.5, zorder=2) + cbar = plt.colorbar(scatter, ax=ax, pad=0.02, shrink=0.8) + cbar.set_label(color_label or color_by, fontsize=12, fontweight='bold') + cbar.ax.tick_params(labelsize=10) + else: + ax.scatter(x, y, c=point_color, alpha=0.4, s=50, + edgecolors='white', linewidth=0.3, zorder=2, label='Design candidates') + + # Draw Pareto front fill area (visual emphasis) + if len(pareto_sorted) > 1: + # Smooth interpolation for the Pareto front line + from scipy.interpolate import interp1d + if len(pareto_sorted) >= 4: + # Use cubic interpolation for smooth curve + try: + f = interp1d(pareto_sorted[:, 0], pareto_sorted[:, 1], kind='cubic') + x_smooth = np.linspace(pareto_sorted[:, 0].min(), pareto_sorted[:, 0].max(), 100) + y_smooth = f(x_smooth) + ax.plot(x_smooth, y_smooth, color=pareto_color, linewidth=3, alpha=0.9, zorder=3) + except: + ax.plot(pareto_sorted[:, 0], pareto_sorted[:, 1], color=pareto_color, + linewidth=3, alpha=0.9, zorder=3) + else: + ax.plot(pareto_sorted[:, 0], pareto_sorted[:, 1], color=pareto_color, + linewidth=3, alpha=0.9, zorder=3) + + # Plot Pareto front points with emphasis + ax.scatter(x[pareto_idx], y[pareto_idx], c=pareto_fill, s=180, + edgecolors=pareto_color, linewidth=2.5, zorder=5, + label=f'Pareto optimal ({len(pareto_idx)} designs)') + + # Styling + ax.set_xlabel(x_label, fontsize=14, fontweight='bold', labelpad=12) + ax.set_ylabel(y_label, fontsize=14, fontweight='bold', labelpad=12) + ax.set_title(title, fontsize=18, fontweight='bold', pad=20, color='#212529') + + # Refined grid + ax.grid(True, alpha=0.5, linestyle='-', linewidth=0.5, color=grid_color) + ax.set_axisbelow(True) + + # Add minor grid + ax.minorticks_on() + ax.grid(True, which='minor', alpha=0.2, linestyle=':', linewidth=0.3, color=grid_color) + + # Legend with professional styling + legend = ax.legend(loc='upper right', fontsize=11, framealpha=0.95, + edgecolor=grid_color, fancybox=True, shadow=True) + + # Add annotation for best point + if minimize_y: + best_idx = pareto_idx[np.argmin(y[pareto_idx])] + else: + best_idx = pareto_idx[np.argmax(y[pareto_idx])] + + # Professional annotation box - position dynamically based on data location + # Determine best quadrant for annotation + x_range = x.max() - x.min() + y_range = y.max() - y.min() + x_mid = x.min() + x_range / 2 + y_mid = y.min() + y_range / 2 + + # Place annotation away from the best point + if x[best_idx] < x_mid: + x_offset = 50 + else: + x_offset = -120 + if y[best_idx] < y_mid: + y_offset = 50 + else: + y_offset = -60 + + ax.annotate(f'Best WFE: {y[best_idx]:.2f}\n{x_label.split()[0]}: {x[best_idx]:.1f}', + xy=(x[best_idx], y[best_idx]), + xytext=(x_offset, y_offset), textcoords='offset points', + fontsize=11, fontweight='bold', + bbox=dict(boxstyle='round,pad=0.6', facecolor='white', + edgecolor=pareto_color, linewidth=2, alpha=0.95), + arrowprops=dict(arrowstyle='->', connectionstyle='arc3,rad=0.2', + color=pareto_color, lw=2)) + + # Statistics box in bottom left + stats_text = f'Total designs explored: {len(df_clean):,}\nPareto optimal: {len(pareto_idx)}' + ax.text(0.02, 0.02, stats_text, transform=ax.transAxes, fontsize=10, + verticalalignment='bottom', + bbox=dict(boxstyle='round,pad=0.5', facecolor='white', + edgecolor=grid_color, alpha=0.9)) + + # Adjust spines + for spine in ax.spines.values(): + spine.set_color(grid_color) + spine.set_linewidth(1.5) + + plt.tight_layout() + plt.savefig(filename, dpi=200, bbox_inches='tight', facecolor='white', + edgecolor='none', pad_inches=0.2) + plt.close() + print(f"Saved: {filename}") + + return pareto_sorted, pareto_idx + + +# Create plots +output_dir = r'c:\Users\antoi\Atomizer\studies' + +# 1. Blank Thickness vs 60/20 WFE +print("\n--- Creating Blank Thickness vs WFE plot ---") +if df_valid[thickness_col].notna().any(): + result = create_pareto_plot( + df_valid, + x_col=thickness_col, + y_col=wfe_col, + x_label='Blank Thickness (mm)', + y_label='60/20 WFE (Relative RMS)', + title='M1 Mirror Optimization\nBlank Thickness vs Wavefront Error', + filename=f'{output_dir}\\pareto_thickness_vs_wfe.png', + minimize_x=False, # Thinner may be desirable + minimize_y=True, # Lower WFE is better + color_by='mass_kg' if 'mass_kg' in df_valid.columns else None, + color_label='Mass (kg)' + ) +else: + print("No thickness data available") + +# 2. Blank Backface Angle vs 60/20 WFE +print("\n--- Creating Backface Angle vs WFE plot ---") +if df_valid[angle_col].notna().any(): + result = create_pareto_plot( + df_valid, + x_col=angle_col, + y_col=wfe_col, + x_label='Blank Backface Angle (degrees)', + y_label='60/20 WFE (Relative RMS)', + title='M1 Mirror Optimization\nBackface Angle vs Wavefront Error', + filename=f'{output_dir}\\pareto_angle_vs_wfe.png', + minimize_x=False, + minimize_y=True, + color_by='mass_kg' if 'mass_kg' in df_valid.columns else None, + color_label='Mass (kg)' + ) +else: + print("No backface angle data available") + +# 3. Combined 2D Design Space plot +print("\n--- Creating Design Space plot ---") +if df_valid[thickness_col].notna().any() and df_valid[angle_col].notna().any(): + mask = df_valid[thickness_col].notna() & df_valid[angle_col].notna() + df_both = df_valid[mask].copy() + + if len(df_both) > 0: + fig, ax = plt.subplots(figsize=(12, 9)) + fig.patch.set_facecolor('white') + + bg_color = '#f8f9fa' + grid_color = '#dee2e6' + ax.set_facecolor(bg_color) + + # Use a perceptually uniform colormap + scatter = ax.scatter( + df_both[thickness_col], + df_both[angle_col], + c=df_both[wfe_col], + cmap='RdYlGn_r', # Red=bad (high WFE), Green=good (low WFE) + s=100, + alpha=0.8, + edgecolors='white', + linewidth=0.5, + vmin=df_both[wfe_col].quantile(0.05), + vmax=df_both[wfe_col].quantile(0.95) + ) + + cbar = plt.colorbar(scatter, ax=ax, pad=0.02, shrink=0.85) + cbar.set_label('60/20 WFE (Relative RMS)\nLower = Better Performance', + fontsize=12, fontweight='bold') + cbar.ax.tick_params(labelsize=10) + + ax.set_xlabel('Blank Thickness (mm)', fontsize=14, fontweight='bold', labelpad=12) + ax.set_ylabel('Blank Backface Angle (degrees)', fontsize=14, fontweight='bold', labelpad=12) + ax.set_title('M1 Mirror Design Space Exploration\nGeometric Parameters vs Optical Performance', + fontsize=18, fontweight='bold', pad=20) + + ax.grid(True, alpha=0.5, color=grid_color) + ax.minorticks_on() + ax.grid(True, which='minor', alpha=0.2, linestyle=':', color=grid_color) + + # Mark best point with star + best_idx = df_both[wfe_col].idxmin() + best_row = df_both.loc[best_idx] + ax.scatter(best_row[thickness_col], best_row[angle_col], + c='#ffc107', s=400, marker='*', edgecolors='#dc3545', linewidth=3, + zorder=5, label=f'Best Design (WFE={best_row[wfe_col]:.2f})') + + # Add annotation for best point - position in upper left to avoid overlap + ax.annotate(f'Best Design\nThickness: {best_row[thickness_col]:.1f}mm\nAngle: {best_row[angle_col]:.2f}Β°\nWFE: {best_row[wfe_col]:.2f}', + xy=(best_row[thickness_col], best_row[angle_col]), + xytext=(-100, 60), textcoords='offset points', + fontsize=10, fontweight='bold', + bbox=dict(boxstyle='round,pad=0.6', facecolor='white', + edgecolor='#dc3545', linewidth=2, alpha=0.95), + arrowprops=dict(arrowstyle='->', connectionstyle='arc3,rad=0.3', + color='#dc3545', lw=2)) + + ax.legend(loc='upper right', fontsize=11, framealpha=0.95, fancybox=True, shadow=True) + + # Stats + stats_text = f'Designs evaluated: {len(df_both):,}' + ax.text(0.02, 0.02, stats_text, transform=ax.transAxes, fontsize=10, + verticalalignment='bottom', + bbox=dict(boxstyle='round,pad=0.5', facecolor='white', + edgecolor=grid_color, alpha=0.9)) + + for spine in ax.spines.values(): + spine.set_color(grid_color) + spine.set_linewidth(1.5) + + plt.tight_layout() + plt.savefig(f'{output_dir}\\design_space_wfe.png', + dpi=200, bbox_inches='tight', facecolor='white', pad_inches=0.2) + plt.close() + print(f"Saved: design_space_wfe.png") +else: + print("Not enough data for combined design space plot") + +print("\n" + "="*60) +print("PARETO VISUALIZATION COMPLETE") +print("="*60) +print(f"\nOutput files saved to: {output_dir}") +print("\nFiles created:") +print(" 1. pareto_thickness_vs_wfe.png - Thickness vs WFE Pareto front") +print(" 2. pareto_angle_vs_wfe.png - Backface Angle vs WFE Pareto front") +print(" 3. design_space_wfe.png - Combined design space heatmap") diff --git a/tools/extract_all_mirror_data.py b/tools/extract_all_mirror_data.py new file mode 100644 index 00000000..383749a5 --- /dev/null +++ b/tools/extract_all_mirror_data.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python +""" +Extract all M1 mirror optimization trial data from Optuna study databases. +Outputs a consolidated CSV file with all parameters and objectives. +""" + +import sqlite3 +import json +import csv +from pathlib import Path +from collections import defaultdict + +# Studies to extract (in order) +STUDIES = [ + "m1_mirror_zernike_optimization", + "m1_mirror_adaptive_V11", + "m1_mirror_adaptive_V13", + "m1_mirror_adaptive_V14", + "m1_mirror_adaptive_V15", + "m1_mirror_cost_reduction", + "m1_mirror_cost_reduction_V2", +] + +# All possible design variables (superset across all studies) +DESIGN_VARS = [ + "lateral_inner_angle", + "lateral_outer_angle", + "lateral_outer_pivot", + "lateral_inner_pivot", + "lateral_middle_pivot", + "lateral_closeness", + "whiffle_min", + "whiffle_outer_to_vertical", + "whiffle_triangle_closeness", + "blank_backface_angle", + "inner_circular_rib_dia", + "center_thickness", +] + +# All objectives +OBJECTIVES = [ + "rel_filtered_rms_40_vs_20", + "rel_filtered_rms_60_vs_20", + "mfg_90_optician_workload", + "mass_kg", +] + + +def get_db_path(study_name: str) -> Path: + """Get the database path for a study.""" + # Check in M1_Mirror topic folder first (new structure) + base = Path(__file__).parent / "studies" / "M1_Mirror" / study_name + for subdir in ["3_results", "2_results"]: + db_path = base / subdir / "study.db" + if db_path.exists(): + return db_path + # Fallback to flat structure (backwards compatibility) + base = Path(__file__).parent / "studies" / study_name + for subdir in ["3_results", "2_results"]: + db_path = base / subdir / "study.db" + if db_path.exists(): + return db_path + return None + + +def get_config_path(study_name: str) -> Path: + """Get the config path for a study.""" + # Check in M1_Mirror topic folder first (new structure) + config_path = Path(__file__).parent / "studies" / "M1_Mirror" / study_name / "1_setup" / "optimization_config.json" + if config_path.exists(): + return config_path + # Fallback to flat structure + return Path(__file__).parent / "studies" / study_name / "1_setup" / "optimization_config.json" + + +def load_objective_mapping(config_path: Path) -> dict: + """Load objective names from config to map objective_id to name.""" + with open(config_path) as f: + config = json.load(f) + + objectives = config.get("objectives", []) + # objective_id 0, 1, 2, ... maps to objectives in order + return {i: obj["name"] for i, obj in enumerate(objectives)} + + +def extract_trials_from_db(db_path: Path, obj_mapping: dict) -> list: + """Extract all completed trials from an Optuna study database.""" + conn = sqlite3.connect(str(db_path)) + cursor = conn.cursor() + + # Get all completed trials + cursor.execute(""" + SELECT trial_id FROM trials WHERE state = 'COMPLETE' + """) + trial_ids = [row[0] for row in cursor.fetchall()] + + trials = [] + for trial_id in trial_ids: + trial_data = {"trial_id": trial_id} + + # Get parameters + cursor.execute(""" + SELECT param_name, param_value FROM trial_params WHERE trial_id = ? + """, (trial_id,)) + for param_name, param_value in cursor.fetchall(): + trial_data[param_name] = param_value + + # Get individual objective values from user attributes + # (Atomizer stores individual objectives here, weighted_sum in trial_values) + cursor.execute(""" + SELECT key, value_json FROM trial_user_attributes WHERE trial_id = ? + """, (trial_id,)) + for key, value in cursor.fetchall(): + # The value is JSON-encoded (string with quotes for strings, plain for numbers) + try: + # Try to parse as float first + trial_data[key] = float(value) + except ValueError: + # Keep as string (e.g., source tag) + trial_data[key] = value.strip('"') + + trials.append(trial_data) + + conn.close() + return trials + + +def main(): + studies_dir = Path(__file__).parent / "studies" + output_path = studies_dir / "m1_mirror_all_trials_export.csv" + + # CSV header + header = ["study", "trial"] + DESIGN_VARS + OBJECTIVES + + all_rows = [] + stats = {} + + for study_name in STUDIES: + db_path = get_db_path(study_name) + config_path = get_config_path(study_name) + + if not db_path or not db_path.exists(): + print(f"[SKIP] {study_name}: No database found") + stats[study_name] = 0 + continue + + if not config_path.exists(): + print(f"[SKIP] {study_name}: No config found") + stats[study_name] = 0 + continue + + print(f"[LOAD] {study_name}...") + + # Load objective mapping from config + obj_mapping = load_objective_mapping(config_path) + + # Extract trials + trials = extract_trials_from_db(db_path, obj_mapping) + stats[study_name] = len(trials) + + # Convert to rows + for trial in trials: + row = { + "study": study_name, + "trial": trial["trial_id"], + } + # Add design variables + for var in DESIGN_VARS: + row[var] = trial.get(var, "") + # Add objectives + for obj in OBJECTIVES: + row[obj] = trial.get(obj, "") + + all_rows.append(row) + + # Write CSV + with open(output_path, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=header) + writer.writeheader() + writer.writerows(all_rows) + + print(f"\n{'='*60}") + print(f"EXPORT COMPLETE: {output_path}") + print(f"{'='*60}") + print(f"\nTotal trials exported: {len(all_rows)}") + print(f"\nTrials per study:") + for study, count in stats.items(): + print(f" {study}: {count}") + + +if __name__ == "__main__": + main() diff --git a/tools/extract_mirror_optical_specs.py b/tools/extract_mirror_optical_specs.py new file mode 100644 index 00000000..21b09f6d --- /dev/null +++ b/tools/extract_mirror_optical_specs.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python +""" +Extract Mirror Optical Specifications from FEA Mesh Geometry + +This tool analyzes mirror mesh geometry to estimate optical specifications +including focal length, aperture diameter, f-number, and radius of curvature. + +Usage: + # From study directory containing OP2 files + python -m optimization_engine.tools.extract_mirror_optical_specs . + + # From specific OP2 file + python -m optimization_engine.tools.extract_mirror_optical_specs path/to/results.op2 + + # Save to study README + python -m optimization_engine.tools.extract_mirror_optical_specs . --update-readme + +Output: + - Console: Optical specifications summary + - Optional: Updates parent README.md with validated specs + +Author: Atomizer Framework +""" + +from pathlib import Path +import argparse +import sys + +# Add project root to path (tools/ is at project root, so parent is Atomizer/) +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import numpy as np + + +def find_op2_file(path: Path) -> Path: + """Find an OP2 file from path (file or directory).""" + path = Path(path) + + if path.is_file() and path.suffix.lower() == '.op2': + return path + + if path.is_dir(): + # Look in common locations + search_patterns = [ + '**/2_iterations/**/*.op2', + '**/*.op2', + '2_iterations/**/*.op2', + '1_setup/model/*.op2', + ] + + for pattern in search_patterns: + op2_files = list(path.glob(pattern)) + if op2_files: + # Return most recent + return max(op2_files, key=lambda p: p.stat().st_mtime) + + raise FileNotFoundError(f"No OP2 file found in {path}") + + +def extract_optical_specs(op2_path: Path, verbose: bool = True) -> dict: + """ + Extract optical specifications from mirror mesh geometry. + + Args: + op2_path: Path to OP2 file + verbose: Print detailed output + + Returns: + dict with optical specifications + """ + from optimization_engine.extractors.extract_zernike_opd import ( + ZernikeOPDExtractor, + estimate_focal_length_from_geometry + ) + + if verbose: + print(f"Analyzing: {op2_path}") + print("=" * 60) + + extractor = ZernikeOPDExtractor(op2_path) + + # Get geometry + geo = extractor.node_geometry + all_pos = np.array(list(geo.values())) + x, y, z = all_pos[:, 0], all_pos[:, 1], all_pos[:, 2] + + # Compute radius/diameter + r = np.sqrt(x**2 + y**2) + + # Estimate focal length + focal = estimate_focal_length_from_geometry(x, y, z, concave=True) + + # Derived quantities + diameter = 2 * r.max() + f_number = focal / diameter + RoC = 2 * focal # Radius of curvature + sag = r.max()**2 / (4 * focal) # Surface sag at edge + central_obs = r.min() if r.min() > 1.0 else 0.0 # Central obscuration + + # Parabola fit quality check + r_sq = x**2 + y**2 + A = np.column_stack([r_sq, np.ones_like(r_sq)]) + coeffs, _, _, _ = np.linalg.lstsq(A, z, rcond=None) + a, b = coeffs + z_fit = a * r_sq + b + rms_error = np.sqrt(np.mean((z - z_fit)**2)) + + # Determine fit quality + if rms_error < 0.1: + fit_quality = "Excellent" + fit_note = "Focal length estimate is reliable" + elif rms_error < 1.0: + fit_quality = "Good" + fit_note = "Focal length estimate is reasonably accurate" + else: + fit_quality = "Poor" + fit_note = "Consider using explicit focal length from optical design" + + specs = { + 'aperture_diameter_mm': diameter, + 'aperture_radius_mm': r.max(), + 'focal_length_mm': focal, + 'f_number': f_number, + 'radius_of_curvature_mm': RoC, + 'surface_sag_mm': sag, + 'central_obscuration_mm': central_obs, + 'node_count': len(geo), + 'x_range_mm': (x.min(), x.max()), + 'y_range_mm': (y.min(), y.max()), + 'z_range_mm': (z.min(), z.max()), + 'parabola_fit_rms_mm': rms_error, + 'fit_quality': fit_quality, + 'fit_note': fit_note, + 'source_file': str(op2_path), + } + + if verbose: + print() + print("MIRROR OPTICAL SPECIFICATIONS (from mesh geometry)") + print("=" * 60) + print() + print(f"Aperture Diameter: {diameter:.1f} mm ({diameter/1000:.3f} m)") + print(f"Aperture Radius: {r.max():.1f} mm") + if central_obs > 0: + print(f"Central Obscuration: {central_obs:.1f} mm") + print() + print(f"Estimated Focal Length: {focal:.1f} mm ({focal/1000:.3f} m)") + print(f"Radius of Curvature: {RoC:.1f} mm ({RoC/1000:.3f} m)") + print(f"f-number (f/D): f/{f_number:.2f}") + print() + print(f"Surface Sag at Edge: {sag:.2f} mm") + print() + print("--- Mesh Statistics ---") + print(f"Node count: {len(geo)}") + print(f"X range: {x.min():.1f} to {x.max():.1f} mm") + print(f"Y range: {y.min():.1f} to {y.max():.1f} mm") + print(f"Z range: {z.min():.2f} to {z.max():.2f} mm") + print() + print("--- Parabola Fit Quality ---") + print(f"RMS fit residual: {rms_error:.4f} mm ({rms_error*1000:.2f} Β΅m)") + print(f"Quality: {fit_quality} - {fit_note}") + print() + print("=" * 60) + + return specs + + +def generate_readme_section(specs: dict) -> str: + """Generate markdown section for README.""" + return f"""## 2. Optical Prescription + +> **Source**: Estimated from mesh geometry. Validate against optical design. + +| Parameter | Value | Units | Status | +|-----------|-------|-------|--------| +| Aperture Diameter | {specs['aperture_diameter_mm']:.1f} | mm | Estimated | +| Focal Length | {specs['focal_length_mm']:.1f} | mm | Estimated | +| f-number | f/{specs['f_number']:.2f} | - | Computed | +| Radius of Curvature | {specs['radius_of_curvature_mm']:.1f} | mm | Computed (2Γ—f) | +| Central Obscuration | {specs['central_obscuration_mm']:.1f} | mm | From mesh | +| Surface Type | Parabola | - | Assumed | + +**Fit Quality**: {specs['fit_quality']} ({specs['fit_note']}) + +### 2.1 Usage in OPD Extractor + +For rigorous WFE analysis, use explicit focal length: + +```python +from optimization_engine.extractors import ZernikeOPDExtractor + +extractor = ZernikeOPDExtractor( + op2_file, + focal_length={specs['focal_length_mm']:.1f}, # mm - validate against design + concave=True +) +``` +""" + + +def update_readme(study_dir: Path, specs: dict): + """Update parent README with optical specs.""" + readme_path = study_dir / 'README.md' + + if not readme_path.exists(): + print(f"README.md not found at {readme_path}") + return False + + content = readme_path.read_text(encoding='utf-8') + + # Find and replace optical prescription section + new_section = generate_readme_section(specs) + + # Look for existing section + import re + pattern = r'## 2\. Optical Prescription.*?(?=## 3\.|$)' + + if re.search(pattern, content, re.DOTALL): + content = re.sub(pattern, new_section + '\n---\n\n', content, flags=re.DOTALL) + print(f"Updated optical prescription in {readme_path}") + else: + print(f"Could not find '## 2. Optical Prescription' section in {readme_path}") + print("Please add manually or check section numbering.") + return False + + readme_path.write_text(content, encoding='utf-8') + return True + + +def main(): + parser = argparse.ArgumentParser( + description='Extract mirror optical specifications from FEA mesh', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Analyze current study directory + python -m optimization_engine.tools.extract_mirror_optical_specs . + + # Analyze specific OP2 file + python -m optimization_engine.tools.extract_mirror_optical_specs results.op2 + + # Update parent README with specs + python -m optimization_engine.tools.extract_mirror_optical_specs . --update-readme + """ + ) + + parser.add_argument('path', type=str, + help='Path to OP2 file or study directory') + parser.add_argument('--update-readme', action='store_true', + help='Update parent README.md with optical specs') + parser.add_argument('--quiet', '-q', action='store_true', + help='Suppress detailed output') + parser.add_argument('--json', action='store_true', + help='Output specs as JSON') + + args = parser.parse_args() + + try: + path = Path(args.path).resolve() + op2_path = find_op2_file(path) + + specs = extract_optical_specs(op2_path, verbose=not args.quiet and not args.json) + + if args.json: + import json + print(json.dumps(specs, indent=2, default=str)) + + if args.update_readme: + # Find study root (parent of geometry type folder) + study_dir = path if path.is_dir() else path.parent + # Go up to geometry type level + while study_dir.name not in ['studies', ''] and not (study_dir / 'README.md').exists(): + if (study_dir.parent / 'README.md').exists(): + study_dir = study_dir.parent + break + study_dir = study_dir.parent + + if (study_dir / 'README.md').exists(): + update_readme(study_dir, specs) + else: + print(f"Could not find parent README.md to update") + + except FileNotFoundError as e: + print(f"Error: {e}") + sys.exit(1) + except Exception as e: + print(f"Error: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/tools/migrate_studies_to_topics.py b/tools/migrate_studies_to_topics.py new file mode 100644 index 00000000..fd6d6408 --- /dev/null +++ b/tools/migrate_studies_to_topics.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python +""" +Migration script to reorganize studies into topic-based subfolders. + +Run with --dry-run first to preview changes: + python migrate_studies_to_topics.py --dry-run + +Then run without flag to execute: + python migrate_studies_to_topics.py +""" + +import shutil +import argparse +from pathlib import Path + +STUDIES_DIR = Path(__file__).parent / "studies" + +# Topic classification based on study name prefixes +TOPIC_MAPPING = { + 'bracket_': 'Simple_Bracket', + 'drone_gimbal_': 'Drone_Gimbal', + 'm1_mirror_': 'M1_Mirror', + 'uav_arm_': 'UAV_Arm', + 'simple_beam_': 'Simple_Beam', +} + +# Files/folders to skip (not studies) +SKIP_ITEMS = { + 'm1_mirror_all_trials_export.csv', # Data export file + '.gitkeep', + '__pycache__', +} + + +def classify_study(study_name: str) -> str: + """Determine which topic folder a study belongs to.""" + for prefix, topic in TOPIC_MAPPING.items(): + if study_name.startswith(prefix): + return topic + return '_Other' + + +def get_studies_to_migrate(): + """Get list of studies that need migration (not already in topic folders).""" + studies = [] + + for item in STUDIES_DIR.iterdir(): + # Skip non-directories and special items + if not item.is_dir(): + continue + if item.name in SKIP_ITEMS: + continue + if item.name.startswith('.'): + continue + + # Check if this is already a topic folder (contains study subdirs) + # A topic folder would have subdirs with 1_setup folders + is_topic_folder = any( + (sub / "1_setup").exists() + for sub in item.iterdir() + if sub.is_dir() + ) + + if is_topic_folder: + print(f"[SKIP] {item.name} - already a topic folder") + continue + + # Check if this is a study (has 1_setup or optimization_config.json) + is_study = ( + (item / "1_setup").exists() or + (item / "optimization_config.json").exists() + ) + + if is_study: + topic = classify_study(item.name) + studies.append({ + 'name': item.name, + 'source': item, + 'topic': topic, + 'target': STUDIES_DIR / topic / item.name + }) + else: + print(f"[SKIP] {item.name} - not a study (no 1_setup folder)") + + return studies + + +def migrate_studies(dry_run: bool = True): + """Migrate studies to topic folders.""" + studies = get_studies_to_migrate() + + if not studies: + print("\nNo studies to migrate. All studies are already organized.") + return + + # Group by topic for display + by_topic = {} + for s in studies: + if s['topic'] not in by_topic: + by_topic[s['topic']] = [] + by_topic[s['topic']].append(s) + + print("\n" + "="*60) + print("MIGRATION PLAN") + print("="*60) + + for topic in sorted(by_topic.keys()): + print(f"\n{topic}/") + for s in by_topic[topic]: + print(f" +-- {s['name']}/") + + print(f"\nTotal: {len(studies)} studies to migrate") + + if dry_run: + print("\n[DRY RUN] No changes made. Run without --dry-run to execute.") + return + + # Execute migration + print("\n" + "="*60) + print("EXECUTING MIGRATION") + print("="*60) + + # Create topic folders + created_topics = set() + for s in studies: + topic_dir = STUDIES_DIR / s['topic'] + if s['topic'] not in created_topics: + topic_dir.mkdir(exist_ok=True) + created_topics.add(s['topic']) + print(f"[CREATE] {s['topic']}/") + + # Move studies + for s in studies: + try: + shutil.move(str(s['source']), str(s['target'])) + print(f"[MOVE] {s['name']} -> {s['topic']}/{s['name']}") + except Exception as e: + print(f"[ERROR] Failed to move {s['name']}: {e}") + + print("\n" + "="*60) + print("MIGRATION COMPLETE") + print("="*60) + + +def main(): + parser = argparse.ArgumentParser(description="Migrate studies to topic folders") + parser.add_argument('--dry-run', action='store_true', + help='Preview changes without executing') + args = parser.parse_args() + + migrate_studies(dry_run=args.dry_run) + + +if __name__ == "__main__": + main()