367 lines
12 KiB
Python
367 lines
12 KiB
Python
|
|
"""
|
||
|
|
Unit tests for SpecMigrator
|
||
|
|
|
||
|
|
Tests for migrating legacy optimization_config.json to AtomizerSpec v2.0.
|
||
|
|
|
||
|
|
P4.6: Migration tests
|
||
|
|
"""
|
||
|
|
|
||
|
|
import json
|
||
|
|
import pytest
|
||
|
|
import tempfile
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
import sys
|
||
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||
|
|
|
||
|
|
from optimization_engine.config.migrator import SpecMigrator, MigrationError
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================================
|
||
|
|
# Fixtures - Legacy Config Formats
|
||
|
|
# ============================================================================
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def mirror_config() -> dict:
|
||
|
|
"""Legacy mirror/Zernike config format."""
|
||
|
|
return {
|
||
|
|
"study_name": "m1_mirror_test",
|
||
|
|
"description": "Test mirror optimization",
|
||
|
|
"nx_settings": {
|
||
|
|
"sim_file": "model.sim",
|
||
|
|
"nx_install_path": "C:\\Program Files\\Siemens\\NX2506",
|
||
|
|
"simulation_timeout_s": 600
|
||
|
|
},
|
||
|
|
"zernike_settings": {
|
||
|
|
"inner_radius": 100,
|
||
|
|
"outer_radius": 500,
|
||
|
|
"n_modes": 40,
|
||
|
|
"filter_low_orders": 4,
|
||
|
|
"displacement_unit": "mm",
|
||
|
|
"reference_subcase": 1
|
||
|
|
},
|
||
|
|
"design_variables": [
|
||
|
|
{
|
||
|
|
"name": "thickness",
|
||
|
|
"parameter": "thickness",
|
||
|
|
"bounds": [5.0, 15.0],
|
||
|
|
"baseline": 10.0,
|
||
|
|
"units": "mm"
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"name": "rib_angle",
|
||
|
|
"parameter": "rib_angle",
|
||
|
|
"bounds": [20.0, 40.0],
|
||
|
|
"baseline": 30.0,
|
||
|
|
"units": "degrees"
|
||
|
|
}
|
||
|
|
],
|
||
|
|
"objectives": [
|
||
|
|
{"name": "wfe_40_20", "goal": "minimize", "weight": 10.0},
|
||
|
|
{"name": "wfe_mfg", "goal": "minimize", "weight": 1.0},
|
||
|
|
{"name": "mass_kg", "goal": "minimize", "weight": 1.0}
|
||
|
|
],
|
||
|
|
"constraints": [
|
||
|
|
{"name": "mass_limit", "type": "<=", "value": 100.0}
|
||
|
|
],
|
||
|
|
"optimization": {
|
||
|
|
"algorithm": "TPE",
|
||
|
|
"n_trials": 50,
|
||
|
|
"seed": 42
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def structural_config() -> dict:
|
||
|
|
"""Legacy structural/bracket config format."""
|
||
|
|
return {
|
||
|
|
"study_name": "bracket_test",
|
||
|
|
"description": "Test bracket optimization",
|
||
|
|
"simulation_settings": {
|
||
|
|
"sim_file": "bracket.sim",
|
||
|
|
"model_file": "bracket.prt",
|
||
|
|
"solver": "nastran",
|
||
|
|
"solution_type": "SOL101"
|
||
|
|
},
|
||
|
|
"extraction_settings": {
|
||
|
|
"type": "displacement",
|
||
|
|
"node_id": 1000,
|
||
|
|
"component": "magnitude"
|
||
|
|
},
|
||
|
|
"design_variables": [
|
||
|
|
{
|
||
|
|
"name": "thickness",
|
||
|
|
"expression_name": "web_thickness",
|
||
|
|
"min": 2.0,
|
||
|
|
"max": 10.0,
|
||
|
|
"baseline": 5.0,
|
||
|
|
"units": "mm"
|
||
|
|
}
|
||
|
|
],
|
||
|
|
"objectives": [
|
||
|
|
{"name": "displacement", "type": "minimize", "weight": 1.0},
|
||
|
|
{"name": "mass", "direction": "minimize", "weight": 1.0}
|
||
|
|
],
|
||
|
|
"constraints": [
|
||
|
|
{"name": "stress_limit", "type": "<=", "value": 200.0}
|
||
|
|
],
|
||
|
|
"optimization_settings": {
|
||
|
|
"sampler": "CMA-ES",
|
||
|
|
"n_trials": 100,
|
||
|
|
"sigma0": 0.3
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def minimal_legacy_config() -> dict:
|
||
|
|
"""Minimal legacy config for edge case testing."""
|
||
|
|
return {
|
||
|
|
"study_name": "minimal",
|
||
|
|
"design_variables": [
|
||
|
|
{"name": "x", "bounds": [0, 1]}
|
||
|
|
],
|
||
|
|
"objectives": [
|
||
|
|
{"name": "y", "goal": "minimize"}
|
||
|
|
]
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================================
|
||
|
|
# Migration Tests
|
||
|
|
# ============================================================================
|
||
|
|
|
||
|
|
class TestSpecMigrator:
|
||
|
|
"""Tests for SpecMigrator."""
|
||
|
|
|
||
|
|
def test_migrate_mirror_config(self, mirror_config):
|
||
|
|
"""Test migration of mirror/Zernike config."""
|
||
|
|
migrator = SpecMigrator()
|
||
|
|
spec = migrator.migrate(mirror_config)
|
||
|
|
|
||
|
|
# Check meta
|
||
|
|
assert spec["meta"]["version"] == "2.0"
|
||
|
|
assert spec["meta"]["study_name"] == "m1_mirror_test"
|
||
|
|
assert "mirror" in spec["meta"]["tags"]
|
||
|
|
|
||
|
|
# Check model
|
||
|
|
assert spec["model"]["sim"]["path"] == "model.sim"
|
||
|
|
|
||
|
|
# Check design variables
|
||
|
|
assert len(spec["design_variables"]) == 2
|
||
|
|
dv = spec["design_variables"][0]
|
||
|
|
assert dv["bounds"]["min"] == 5.0
|
||
|
|
assert dv["bounds"]["max"] == 15.0
|
||
|
|
assert dv["expression_name"] == "thickness"
|
||
|
|
|
||
|
|
# Check extractors
|
||
|
|
assert len(spec["extractors"]) >= 1
|
||
|
|
ext = spec["extractors"][0]
|
||
|
|
assert ext["type"] == "zernike_opd"
|
||
|
|
assert ext["config"]["outer_radius_mm"] == 500
|
||
|
|
|
||
|
|
# Check objectives
|
||
|
|
assert len(spec["objectives"]) == 3
|
||
|
|
obj = spec["objectives"][0]
|
||
|
|
assert obj["direction"] == "minimize"
|
||
|
|
|
||
|
|
# Check optimization
|
||
|
|
assert spec["optimization"]["algorithm"]["type"] == "TPE"
|
||
|
|
assert spec["optimization"]["budget"]["max_trials"] == 50
|
||
|
|
|
||
|
|
def test_migrate_structural_config(self, structural_config):
|
||
|
|
"""Test migration of structural/bracket config."""
|
||
|
|
migrator = SpecMigrator()
|
||
|
|
spec = migrator.migrate(structural_config)
|
||
|
|
|
||
|
|
# Check meta
|
||
|
|
assert spec["meta"]["version"] == "2.0"
|
||
|
|
|
||
|
|
# Check model
|
||
|
|
assert spec["model"]["sim"]["path"] == "bracket.sim"
|
||
|
|
assert spec["model"]["sim"]["solver"] == "nastran"
|
||
|
|
|
||
|
|
# Check design variables
|
||
|
|
assert len(spec["design_variables"]) == 1
|
||
|
|
dv = spec["design_variables"][0]
|
||
|
|
assert dv["expression_name"] == "web_thickness"
|
||
|
|
assert dv["bounds"]["min"] == 2.0
|
||
|
|
assert dv["bounds"]["max"] == 10.0
|
||
|
|
|
||
|
|
# Check optimization
|
||
|
|
assert spec["optimization"]["algorithm"]["type"] == "CMA-ES"
|
||
|
|
assert spec["optimization"]["algorithm"]["config"]["sigma0"] == 0.3
|
||
|
|
|
||
|
|
def test_migrate_minimal_config(self, minimal_legacy_config):
|
||
|
|
"""Test migration handles minimal configs."""
|
||
|
|
migrator = SpecMigrator()
|
||
|
|
spec = migrator.migrate(minimal_legacy_config)
|
||
|
|
|
||
|
|
assert spec["meta"]["study_name"] == "minimal"
|
||
|
|
assert len(spec["design_variables"]) == 1
|
||
|
|
assert spec["design_variables"][0]["bounds"]["min"] == 0
|
||
|
|
assert spec["design_variables"][0]["bounds"]["max"] == 1
|
||
|
|
|
||
|
|
def test_bounds_normalization(self):
|
||
|
|
"""Test bounds array to object conversion."""
|
||
|
|
config = {
|
||
|
|
"study_name": "bounds_test",
|
||
|
|
"design_variables": [
|
||
|
|
{"name": "a", "bounds": [1.0, 5.0]}, # Array format
|
||
|
|
{"name": "b", "bounds": {"min": 2.0, "max": 6.0}}, # Object format
|
||
|
|
{"name": "c", "min": 3.0, "max": 7.0} # Separate fields
|
||
|
|
],
|
||
|
|
"objectives": [{"name": "y", "goal": "minimize"}]
|
||
|
|
}
|
||
|
|
migrator = SpecMigrator()
|
||
|
|
spec = migrator.migrate(config)
|
||
|
|
|
||
|
|
assert spec["design_variables"][0]["bounds"] == {"min": 1.0, "max": 5.0}
|
||
|
|
assert spec["design_variables"][1]["bounds"] == {"min": 2.0, "max": 6.0}
|
||
|
|
assert spec["design_variables"][2]["bounds"] == {"min": 3.0, "max": 7.0}
|
||
|
|
|
||
|
|
def test_degenerate_bounds_fixed(self):
|
||
|
|
"""Test that min >= max is fixed."""
|
||
|
|
config = {
|
||
|
|
"study_name": "degenerate",
|
||
|
|
"design_variables": [
|
||
|
|
{"name": "zero", "bounds": [0.0, 0.0]},
|
||
|
|
{"name": "reverse", "bounds": [10.0, 5.0]}
|
||
|
|
],
|
||
|
|
"objectives": [{"name": "y", "goal": "minimize"}]
|
||
|
|
}
|
||
|
|
migrator = SpecMigrator()
|
||
|
|
spec = migrator.migrate(config)
|
||
|
|
|
||
|
|
# Zero bounds should be expanded
|
||
|
|
dv0 = spec["design_variables"][0]
|
||
|
|
assert dv0["bounds"]["min"] < dv0["bounds"]["max"]
|
||
|
|
|
||
|
|
# Reversed bounds should be expanded around min
|
||
|
|
dv1 = spec["design_variables"][1]
|
||
|
|
assert dv1["bounds"]["min"] < dv1["bounds"]["max"]
|
||
|
|
|
||
|
|
def test_algorithm_normalization(self):
|
||
|
|
"""Test algorithm name normalization."""
|
||
|
|
test_cases = [
|
||
|
|
("tpe", "TPE"),
|
||
|
|
("TPESampler", "TPE"),
|
||
|
|
("cma-es", "CMA-ES"),
|
||
|
|
("NSGA-II", "NSGA-II"),
|
||
|
|
("random", "RandomSearch"),
|
||
|
|
("turbo", "SAT_v3"),
|
||
|
|
("unknown_algo", "TPE"), # Falls back to TPE
|
||
|
|
]
|
||
|
|
|
||
|
|
for old_algo, expected in test_cases:
|
||
|
|
config = {
|
||
|
|
"study_name": f"algo_test_{old_algo}",
|
||
|
|
"design_variables": [{"name": "x", "bounds": [0, 1]}],
|
||
|
|
"objectives": [{"name": "y", "goal": "minimize"}],
|
||
|
|
"optimization": {"algorithm": old_algo}
|
||
|
|
}
|
||
|
|
migrator = SpecMigrator()
|
||
|
|
spec = migrator.migrate(config)
|
||
|
|
assert spec["optimization"]["algorithm"]["type"] == expected, f"Failed for {old_algo}"
|
||
|
|
|
||
|
|
def test_objective_direction_normalization(self):
|
||
|
|
"""Test objective direction normalization."""
|
||
|
|
config = {
|
||
|
|
"study_name": "direction_test",
|
||
|
|
"design_variables": [{"name": "x", "bounds": [0, 1]}],
|
||
|
|
"objectives": [
|
||
|
|
{"name": "a", "goal": "minimize"},
|
||
|
|
{"name": "b", "type": "maximize"},
|
||
|
|
{"name": "c", "direction": "minimize"},
|
||
|
|
{"name": "d"} # No direction - should default
|
||
|
|
]
|
||
|
|
}
|
||
|
|
migrator = SpecMigrator()
|
||
|
|
spec = migrator.migrate(config)
|
||
|
|
|
||
|
|
assert spec["objectives"][0]["direction"] == "minimize"
|
||
|
|
assert spec["objectives"][1]["direction"] == "maximize"
|
||
|
|
assert spec["objectives"][2]["direction"] == "minimize"
|
||
|
|
assert spec["objectives"][3]["direction"] == "minimize" # Default
|
||
|
|
|
||
|
|
def test_canvas_edges_generated(self, mirror_config):
|
||
|
|
"""Test that canvas edges are auto-generated."""
|
||
|
|
migrator = SpecMigrator()
|
||
|
|
spec = migrator.migrate(mirror_config)
|
||
|
|
|
||
|
|
assert "canvas" in spec
|
||
|
|
assert "edges" in spec["canvas"]
|
||
|
|
assert len(spec["canvas"]["edges"]) > 0
|
||
|
|
|
||
|
|
def test_canvas_positions_assigned(self, mirror_config):
|
||
|
|
"""Test that canvas positions are assigned to all nodes."""
|
||
|
|
migrator = SpecMigrator()
|
||
|
|
spec = migrator.migrate(mirror_config)
|
||
|
|
|
||
|
|
# Design variables should have positions
|
||
|
|
for dv in spec["design_variables"]:
|
||
|
|
assert "canvas_position" in dv
|
||
|
|
assert "x" in dv["canvas_position"]
|
||
|
|
assert "y" in dv["canvas_position"]
|
||
|
|
|
||
|
|
# Extractors should have positions
|
||
|
|
for ext in spec["extractors"]:
|
||
|
|
assert "canvas_position" in ext
|
||
|
|
|
||
|
|
# Objectives should have positions
|
||
|
|
for obj in spec["objectives"]:
|
||
|
|
assert "canvas_position" in obj
|
||
|
|
|
||
|
|
|
||
|
|
class TestMigrationFile:
|
||
|
|
"""Tests for file-based migration."""
|
||
|
|
|
||
|
|
def test_migrate_file(self, mirror_config):
|
||
|
|
"""Test migrating from file."""
|
||
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||
|
|
# Create legacy config file
|
||
|
|
config_path = Path(tmpdir) / "optimization_config.json"
|
||
|
|
with open(config_path, "w") as f:
|
||
|
|
json.dump(mirror_config, f)
|
||
|
|
|
||
|
|
# Migrate
|
||
|
|
migrator = SpecMigrator(Path(tmpdir))
|
||
|
|
spec = migrator.migrate_file(config_path)
|
||
|
|
|
||
|
|
assert spec["meta"]["study_name"] == "m1_mirror_test"
|
||
|
|
|
||
|
|
def test_migrate_file_and_save(self, mirror_config):
|
||
|
|
"""Test migrating and saving to file."""
|
||
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||
|
|
config_path = Path(tmpdir) / "optimization_config.json"
|
||
|
|
output_path = Path(tmpdir) / "atomizer_spec.json"
|
||
|
|
|
||
|
|
with open(config_path, "w") as f:
|
||
|
|
json.dump(mirror_config, f)
|
||
|
|
|
||
|
|
migrator = SpecMigrator(Path(tmpdir))
|
||
|
|
spec = migrator.migrate_file(config_path, output_path)
|
||
|
|
|
||
|
|
# Check output file was created
|
||
|
|
assert output_path.exists()
|
||
|
|
|
||
|
|
# Check content
|
||
|
|
with open(output_path) as f:
|
||
|
|
saved_spec = json.load(f)
|
||
|
|
assert saved_spec["meta"]["version"] == "2.0"
|
||
|
|
|
||
|
|
def test_migrate_file_not_found(self):
|
||
|
|
"""Test error on missing file."""
|
||
|
|
migrator = SpecMigrator()
|
||
|
|
with pytest.raises(MigrationError):
|
||
|
|
migrator.migrate_file(Path("nonexistent.json"))
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================================
|
||
|
|
# Run Tests
|
||
|
|
# ============================================================================
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
pytest.main([__file__, "-v"])
|