Files
Atomizer/tools/analysis/debug_lateral_discrepancy.py
Anto01 a3f18dc377 chore: Project cleanup and Canvas UX improvements (Phase 7-9)
## Cleanup (v0.5.0)
- Delete 102+ orphaned MCP session temp files
- Remove build artifacts (htmlcov, dist, __pycache__)
- Archive superseded plan docs (RALPH_LOOP V2/V3, CANVAS V3, etc.)
- Move debug/analysis scripts from tests/ to tools/analysis/
- Archive redundant NX journals to archive/nx_journals/
- Archive monolithic PROTOCOL.md to docs/archive/
- Update .gitignore with missing patterns
- Clean old study files (optimization_log_old.txt, run_optimization_old.py)

## Canvas UX (Phases 7-9)
- Phase 7: Resizable panels with localStorage persistence
  - Left sidebar: 200-400px, Right panel: 280-600px
  - New useResizablePanel hook and ResizeHandle component
- Phase 8: Enable all palette items
  - All 8 node types now draggable
  - Singleton logic for model/solver/algorithm/surrogate
- Phase 9: Solver configuration
  - Add SolverEngine type (nxnastran, mscnastran, python, etc.)
  - Add NastranSolutionType (SOL101-SOL200)
  - Engine/solution dropdowns in config panel
  - Python script path support

## Documentation
- Update CHANGELOG.md with recent versions
- Update docs/00_INDEX.md
- Create examples/README.md
- Add docs/plans/CANVAS_UX_IMPROVEMENTS.md
2026-01-24 15:17:34 -05:00

182 lines
6.0 KiB
Python

"""
Debug script to investigate the lateral displacement discrepancy between
Zernike OPD output and Simcenter post-processing.
User observation:
- Zernike OPD shows max lateral XY displacement: ~0.2238 µm
- Simcenter shows XX displacement at 20deg: 7.457e-05 mm = 0.0746 µm
Hypothesis: The Zernike "max_lateral_disp_um" is sqrt(dx² + dy²), not just dx or dy.
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import numpy as np
from pathlib import Path
# Find a mirror study OP2 to analyze
STUDIES_PATH = Path(r"c:\Users\antoi\Atomizer\studies\M1_Mirror")
def find_latest_op2():
"""Find a recent OP2 file to analyze."""
op2_files = list(STUDIES_PATH.rglob("*.op2"))
if not op2_files:
print("No OP2 files found!")
return None
# Get most recent
return max(op2_files, key=lambda p: p.stat().st_mtime)
def analyze_displacements(op2_path: Path):
"""Analyze displacement components for all subcases."""
from pyNastran.op2.op2 import OP2
from pyNastran.bdf.bdf import BDF
print(f"\n{'='*70}")
print(f"Analyzing: {op2_path.name}")
print(f"Path: {op2_path}")
print(f"{'='*70}")
# Find BDF
bdf_path = None
for ext in ['.dat', '.bdf']:
candidate = op2_path.with_suffix(ext)
if candidate.exists():
bdf_path = candidate
break
if not bdf_path:
print("No BDF found, searching parent...")
for f in op2_path.parent.iterdir():
if f.suffix.lower() in ['.dat', '.bdf']:
bdf_path = f
break
if not bdf_path:
print("ERROR: No geometry file found!")
return
print(f"BDF: {bdf_path.name}")
# Read data
print("\nLoading OP2...")
op2 = OP2()
op2.read_op2(str(op2_path))
print("Loading BDF...")
bdf = BDF()
bdf.read_bdf(str(bdf_path))
node_geo = {int(nid): node.get_position() for nid, node in bdf.nodes.items()}
SUBCASE_MAP = {'1': 90, '2': 20, '3': 40, '4': 60}
print(f"\nNode count in BDF: {len(node_geo)}")
print(f"\n{'='*70}")
print("DISPLACEMENT ANALYSIS BY SUBCASE")
print(f"{'='*70}")
for key, darr in op2.displacements.items():
data = darr.data
dmat = data[0] if data.ndim == 3 else (data if data.ndim == 2 else None)
if dmat is None:
continue
ngt = darr.node_gridtype.astype(int)
node_ids = ngt if ngt.ndim == 1 else ngt[:, 0]
isubcase = getattr(darr, 'isubcase', None)
label = str(isubcase) if isubcase else str(key)
angle = SUBCASE_MAP.get(label, label)
print(f"\n--- Subcase {label} ({angle} deg) ---")
print(f"Nodes with displacement: {len(node_ids)}")
# Extract displacement components
dx = dmat[:, 0] # X displacement (mm)
dy = dmat[:, 1] # Y displacement (mm)
dz = dmat[:, 2] # Z displacement (mm)
# Compute statistics
dx_um = dx * 1000.0 # Convert mm to µm
dy_um = dy * 1000.0
dz_um = dz * 1000.0
# Lateral magnitude (XY combined)
lateral_um = np.sqrt(dx_um**2 + dy_um**2)
print(f"\nComponent Statistics (in um):")
print(f" X displacement (dx):")
print(f" Min: {np.min(dx_um):+.4f} um")
print(f" Max: {np.max(dx_um):+.4f} um")
print(f" RMS: {np.sqrt(np.mean(dx_um**2)):.4f} um")
print(f" Y displacement (dy):")
print(f" Min: {np.min(dy_um):+.4f} um")
print(f" Max: {np.max(dy_um):+.4f} um")
print(f" RMS: {np.sqrt(np.mean(dy_um**2)):.4f} um")
print(f" Z displacement (dz):")
print(f" Min: {np.min(dz_um):+.4f} um")
print(f" Max: {np.max(dz_um):+.4f} um")
print(f" RMS: {np.sqrt(np.mean(dz_um**2)):.4f} um")
print(f"\n Lateral Magnitude (sqrt(dx^2 + dy^2)):")
print(f" Max: {np.max(lateral_um):.4f} um <-- This is what Zernike OPD reports!")
print(f" RMS: {np.sqrt(np.mean(lateral_um**2)):.4f} um")
# Find the node with max lateral displacement
max_lat_idx = np.argmax(lateral_um)
max_lat_nid = int(node_ids[max_lat_idx])
max_dx = dx_um[max_lat_idx]
max_dy = dy_um[max_lat_idx]
print(f"\n Node with max lateral displacement: Node {max_lat_nid}")
print(f" dx = {max_dx:+.4f} um")
print(f" dy = {max_dy:+.4f} um")
print(f" sqrt(dx^2+dy^2) = {lateral_um[max_lat_idx]:.4f} um")
# Compare with just max(|dx|)
max_abs_dx = np.max(np.abs(dx_um))
max_abs_dy = np.max(np.abs(dy_um))
print(f"\n For comparison (what you see in Simcenter):")
print(f" max(|dx|) = {max_abs_dx:.4f} um")
print(f" max(|dy|) = {max_abs_dy:.4f} um")
print(f"\n{'='*70}")
print("EXPLANATION OF DISCREPANCY")
print(f"{'='*70}")
print("""
The Zernike OPD insight reports "max_lateral_disp_um" as:
lateral = sqrt(dx^2 + dy^2) -- combined XY magnitude at each node
Simcenter's "Displacement - Nodal, X" shows:
Just the X component (dx) at each node
These are different metrics:
- If dx_max = 0.0746 um and dy is significant, then:
lateral = sqrt(0.0746^2 + dy^2) > 0.0746 um
To match Simcenter exactly, look at the individual dx/dy/dz stats above.
The "max_lateral_um" in Zernike OPD is the MAGNITUDE of the XY vector,
not the individual X or Y components.
For a node where both dx and dy are non-zero:
dx = 0.0746 um, dy = 0.21 um
lateral = sqrt(0.0746^2 + 0.21^2) = sqrt(0.0056 + 0.044) = sqrt(0.0496) = 0.22 um
""")
if __name__ == '__main__':
# Find and analyze an OP2 file
if len(sys.argv) > 1:
op2_path = Path(sys.argv[1])
else:
op2_path = find_latest_op2()
if op2_path and op2_path.exists():
analyze_displacements(op2_path)
else:
print("Please provide an OP2 file path as argument, or place OP2 files in the studies directory.")
print(f"\nUsage: python {sys.argv[0]} <path_to_op2_file>")