diff --git a/configs/thickness_sweep.yaml b/configs/thickness_sweep.yaml new file mode 100644 index 0000000..b7cabc2 --- /dev/null +++ b/configs/thickness_sweep.yaml @@ -0,0 +1,13 @@ +base_config: configs/baseline_crossmember.yaml + +thickness_values_m: + - 0.0010 + - 0.0012 + - 0.0014 + - 0.0016 + - 0.0018 + - 0.0020 + +output_csv: docs/validation/thickness_sweep.csv + +max_workers: 4 \ No newline at end of file diff --git a/docs/figures/thickness_vs_stress.png b/docs/figures/thickness_vs_stress.png new file mode 100644 index 0000000..2e8638d Binary files /dev/null and b/docs/figures/thickness_vs_stress.png differ diff --git a/docs/validation/thickness_sweep.csv b/docs/validation/thickness_sweep.csv new file mode 100644 index 0000000..18c6bb1 --- /dev/null +++ b/docs/validation/thickness_sweep.csv @@ -0,0 +1,7 @@ +thickness_mm,max_stress_mpa,deflection_mm,mode1_hz,mass_kg +1.0,258.099,22.537209999999998,50.008,1.8526000000000007 +1.2,217.937,19.029770000000003,49.76519,2.215584 +1.4,189.294,16.528290000000002,49.52232,2.5760559999999972 +1.6,167.852,14.655619999999999,49.27939,2.9340159999999993 +1.8,151.21,13.20223,49.03639,3.2894640000000024 +2.0,137.93,12.04239,48.79333,3.6424 diff --git a/scripts/plot_thickness_sweep.py b/scripts/plot_thickness_sweep.py new file mode 100644 index 0000000..1224209 --- /dev/null +++ b/scripts/plot_thickness_sweep.py @@ -0,0 +1,32 @@ +import matplotlib.pyplot as plt +import pandas as pd + + +def main() -> None: + dataframe = pd.read_csv("docs/validation/thickness_sweep.csv") + + plt.figure(figsize=(8, 5)) + + plt.plot( + dataframe["thickness_mm"], + dataframe["max_stress_mpa"], + marker="o", + ) + + plt.xlabel("Wall thickness [mm]") + plt.ylabel("Maximum axial stress [MPa]") + plt.title("BodySimPy Thickness Sweep — Stress") + + plt.grid(True) + plt.tight_layout() + + plt.savefig( + "docs/figures/thickness_vs_stress.png", + dpi=200, + ) + + plt.close() + + +if __name__ == "__main__": + main() diff --git a/src/bodysimpy/analysis/parameter_sweep.py b/src/bodysimpy/analysis/parameter_sweep.py new file mode 100644 index 0000000..821713e --- /dev/null +++ b/src/bodysimpy/analysis/parameter_sweep.py @@ -0,0 +1,149 @@ +import csv +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, replace +from pathlib import Path + +from bodysimpy.domain.structural_model import ( + StructuralModel, +) +from bodysimpy.solvers.calculix import ( + CalculiXSolver, +) + + +@dataclass(frozen=True, slots=True) +class SweepPoint: + """Results from one wall-thickness design point.""" + + thickness_m: float + max_stress_pa: float + tip_deflection_m: float + mode_1_frequency_hz: float + mass_kg: float + + +def _evaluate_thickness( + model: StructuralModel, + thickness_m: float, +) -> SweepPoint: + if thickness_m <= 0.0: + raise ValueError("Sweep thickness must be positive.") + + section = replace( + model.section, + thickness_m=thickness_m, + ) + + thickness_mm = thickness_m * 1000.0 + + thickness_tag = f"{thickness_mm:.3f}".replace(".", "p") + + variant = replace( + model, + name=(f"{model.name}_thickness_{thickness_tag}mm"), + section=section, + ) + + solver = CalculiXSolver() + + static_result = solver.run(variant) + + modal_result = solver.run_modal( + variant, + modes=1, + ) + + if static_result.max_axial_stress_pa is None: + raise ValueError("Static solver returned no axial stress.") + + mass_kg = variant.material.density_kg_m3 * variant.section.area_m2 * variant.length_m + + return SweepPoint( + thickness_m=thickness_m, + max_stress_pa=(static_result.max_axial_stress_pa), + tip_deflection_m=(static_result.tip_deflection_m), + mode_1_frequency_hz=(modal_result.natural_frequencies_hz[0]), + mass_kg=mass_kg, + ) + + +def run_thickness_sweep( + model: StructuralModel, + *, + thickness_values_m: tuple[float, ...], + max_workers: int = 1, +) -> tuple[SweepPoint, ...]: + """Evaluate a wall-thickness parameter sweep.""" + + if not thickness_values_m: + raise ValueError("Thickness sweep must contain at least one value.") + + if max_workers <= 0: + raise ValueError("Maximum worker count must be positive.") + + if max_workers == 1: + return tuple( + _evaluate_thickness( + model, + thickness, + ) + for thickness in thickness_values_m + ) + + def evaluate( + thickness_m: float, + ) -> SweepPoint: + return _evaluate_thickness( + model, + thickness_m, + ) + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + return tuple( + executor.map( + evaluate, + thickness_values_m, + ) + ) + + +def write_sweep_csv( + points: tuple[SweepPoint, ...], + path: str | Path, +) -> None: + """Write parameter-sweep results to CSV.""" + + output_path = Path(path) + + output_path.parent.mkdir( + parents=True, + exist_ok=True, + ) + + with output_path.open( + "w", + encoding="utf-8", + newline="", + ) as stream: + writer = csv.writer(stream) + + writer.writerow( + [ + "thickness_mm", + "max_stress_mpa", + "deflection_mm", + "mode1_hz", + "mass_kg", + ] + ) + + for point in points: + writer.writerow( + [ + point.thickness_m * 1000.0, + point.max_stress_pa / 1e6, + point.tip_deflection_m * 1000.0, + point.mode_1_frequency_hz, + point.mass_kg, + ] + ) diff --git a/src/bodysimpy/cli.py b/src/bodysimpy/cli.py index 1ce6c8a..111bd9d 100644 --- a/src/bodysimpy/cli.py +++ b/src/bodysimpy/cli.py @@ -1,5 +1,11 @@ +from pathlib import Path + import typer +from bodysimpy.workflows.parameter_sweep import ( + run_thickness_sweep_from_config, +) + app = typer.Typer( name="bodysim", help="Structural CAE workflow automation for automotive body structures.", @@ -14,5 +20,25 @@ def version() -> None: typer.echo("BodySimPy 0.1.0") +@app.command() +def sweep( + config_path: Path, +) -> None: + """Run a configured parameter sweep.""" + + points = run_thickness_sweep_from_config(config_path) + + typer.echo(f"Completed {len(points)} sweep points.") + + for point in points: + typer.echo( + f"{point.thickness_m * 1000:.1f} mm | " + f"{point.max_stress_pa / 1e6:.3f} MPa | " + f"{point.tip_deflection_m * 1000:.4f} mm | " + f"{point.mode_1_frequency_hz:.4f} Hz | " + f"{point.mass_kg:.4f} kg" + ) + + if __name__ == "__main__": app() diff --git a/src/bodysimpy/config/loader.py b/src/bodysimpy/config/loader.py index 9543be1..35a74c5 100644 --- a/src/bodysimpy/config/loader.py +++ b/src/bodysimpy/config/loader.py @@ -2,7 +2,10 @@ import yaml -from bodysimpy.config.models import SimulationConfig +from bodysimpy.config.models import ( + SimulationConfig, + ThicknessSweepConfig, +) def load_config(path: str | Path) -> SimulationConfig: @@ -17,3 +20,22 @@ def load_config(path: str | Path) -> SimulationConfig: raise TypeError("Configuration root must be a YAML mapping.") return SimulationConfig.model_validate(raw_config) + + +def load_thickness_sweep_config( + path: str | Path, +) -> ThicknessSweepConfig: + """Load and validate a thickness-sweep YAML configuration.""" + + config_path = Path(path) + + with config_path.open( + "r", + encoding="utf-8", + ) as stream: + raw_config: object = yaml.safe_load(stream) + + if not isinstance(raw_config, dict): + raise TypeError("Sweep configuration root must be a YAML mapping.") + + return ThicknessSweepConfig.model_validate(raw_config) diff --git a/src/bodysimpy/config/models.py b/src/bodysimpy/config/models.py index 3406329..5d7e623 100644 --- a/src/bodysimpy/config/models.py +++ b/src/bodysimpy/config/models.py @@ -76,3 +76,25 @@ class SimulationConfig(StrictModel): analysis: AnalysisConfig mesh: MeshConfig stochastic: StochasticConfig | None = None + + +class ThicknessSweepConfig(StrictModel): + """Configuration for a wall-thickness parameter sweep.""" + + base_config: str = Field(min_length=1) + thickness_values_m: tuple[float, ...] + output_csv: str = Field(min_length=1) + max_workers: int = Field(default=1, gt=0) + + @model_validator(mode="after") + def validate_thickness_values(self) -> Self: + if not self.thickness_values_m: + raise ValueError("At least one thickness value is required.") + + if any(thickness <= 0.0 for thickness in self.thickness_values_m): + raise ValueError("All thickness values must be positive.") + + if len(set(self.thickness_values_m)) != len(self.thickness_values_m): + raise ValueError("Thickness sweep values must be unique.") + + return self diff --git a/src/bodysimpy/workflows/parameter_sweep.py b/src/bodysimpy/workflows/parameter_sweep.py new file mode 100644 index 0000000..8d2cac9 --- /dev/null +++ b/src/bodysimpy/workflows/parameter_sweep.py @@ -0,0 +1,39 @@ +from pathlib import Path + +from bodysimpy.analysis.parameter_sweep import ( + SweepPoint, + run_thickness_sweep, + write_sweep_csv, +) +from bodysimpy.config.loader import ( + load_config, + load_thickness_sweep_config, +) +from bodysimpy.modeling.crossmember import ( + build_crossmember_model, +) + + +def run_thickness_sweep_from_config( + path: str | Path, +) -> tuple[SweepPoint, ...]: + """Run a configured wall-thickness parameter sweep.""" + + sweep_config = load_thickness_sweep_config(path) + + base_config = load_config(sweep_config.base_config) + + model = build_crossmember_model(base_config) + + points = run_thickness_sweep( + model, + thickness_values_m=(sweep_config.thickness_values_m), + max_workers=sweep_config.max_workers, + ) + + write_sweep_csv( + points, + sweep_config.output_csv, + ) + + return points diff --git a/tests/integration/test_parameter_sweep.py b/tests/integration/test_parameter_sweep.py new file mode 100644 index 0000000..090a66a --- /dev/null +++ b/tests/integration/test_parameter_sweep.py @@ -0,0 +1,69 @@ +import shutil + +import pytest + +from bodysimpy.analysis.parameter_sweep import ( + run_thickness_sweep, +) +from bodysimpy.config.loader import load_config +from bodysimpy.modeling.crossmember import ( + build_crossmember_model, +) + + +@pytest.mark.skipif( + shutil.which("ccx") is None, + reason="CalculiX is not installed.", +) +def test_thickness_sweep_runs_real_solver() -> None: + config = load_config("configs/baseline_crossmember.yaml") + + model = build_crossmember_model(config) + + points = run_thickness_sweep( + model, + thickness_values_m=( + 0.0010, + 0.0020, + ), + max_workers=1, + ) + + assert len(points) == 2 + + assert points[0].thickness_m == pytest.approx(0.0010) + + assert points[1].thickness_m == pytest.approx(0.0020) + + for point in points: + assert point.max_stress_pa > 0.0 + assert point.tip_deflection_m > 0.0 + assert point.mode_1_frequency_hz > 0.0 + assert point.mass_kg > 0.0 + + assert points[1].mass_kg > points[0].mass_kg + + +@pytest.mark.skipif( + shutil.which("ccx") is None, + reason="CalculiX is not installed.", +) +def test_parallel_thickness_sweep() -> None: + config = load_config("configs/baseline_crossmember.yaml") + + model = build_crossmember_model(config) + + points = run_thickness_sweep( + model, + thickness_values_m=( + 0.0010, + 0.0015, + ), + max_workers=2, + ) + + assert len(points) == 2 + + assert points[0].thickness_m == pytest.approx(0.0010) + + assert points[1].thickness_m == pytest.approx(0.0015) diff --git a/tests/unit/test_parameter_sweep_output.py b/tests/unit/test_parameter_sweep_output.py new file mode 100644 index 0000000..f5b957e --- /dev/null +++ b/tests/unit/test_parameter_sweep_output.py @@ -0,0 +1,40 @@ +from pathlib import Path + +from bodysimpy.analysis.parameter_sweep import ( + SweepPoint, + write_sweep_csv, +) + + +def test_write_sweep_csv( + tmp_path: Path, +) -> None: + output_path = tmp_path / "sweep.csv" + + points = ( + SweepPoint( + thickness_m=0.001, + max_stress_pa=200e6, + tip_deflection_m=0.010, + mode_1_frequency_hz=45.0, + mass_kg=2.0, + ), + SweepPoint( + thickness_m=0.002, + max_stress_pa=120e6, + tip_deflection_m=0.006, + mode_1_frequency_hz=55.0, + mass_kg=3.5, + ), + ) + + write_sweep_csv( + points, + output_path, + ) + + content = output_path.read_text(encoding="utf-8") + + assert "thickness_mm,max_stress_mpa,deflection_mm,mode1_hz,mass_kg" in content + + assert "1.0,200.0,10.0,45.0,2.0" in content diff --git a/tests/unit/test_sweep_config.py b/tests/unit/test_sweep_config.py new file mode 100644 index 0000000..86f5f91 --- /dev/null +++ b/tests/unit/test_sweep_config.py @@ -0,0 +1,69 @@ +import pytest +from pydantic import ValidationError + +from bodysimpy.config.loader import load_thickness_sweep_config +from bodysimpy.config.models import ThicknessSweepConfig + + +def test_valid_thickness_sweep_configuration() -> None: + config = ThicknessSweepConfig.model_validate( + { + "base_config": "configs/baseline_crossmember.yaml", + "thickness_values_m": [ + 0.0010, + 0.0012, + 0.0014, + ], + "output_csv": "docs/validation/thickness_sweep.csv", + "max_workers": 1, + } + ) + + assert config.thickness_values_m == pytest.approx( + ( + 0.0010, + 0.0012, + 0.0014, + ) + ) + + assert config.max_workers == 1 + + +def test_sweep_rejects_non_positive_thickness() -> None: + with pytest.raises(ValidationError): + ThicknessSweepConfig.model_validate( + { + "base_config": "configs/baseline_crossmember.yaml", + "thickness_values_m": [ + 0.0010, + -0.0012, + ], + "output_csv": "docs/validation/thickness_sweep.csv", + "max_workers": 1, + } + ) + + +def test_sweep_rejects_duplicate_thickness_values() -> None: + with pytest.raises(ValidationError): + ThicknessSweepConfig.model_validate( + { + "base_config": "configs/baseline_crossmember.yaml", + "thickness_values_m": [ + 0.0010, + 0.0010, + ], + "output_csv": "docs/validation/thickness_sweep.csv", + "max_workers": 1, + } + ) + + +def test_load_thickness_sweep_yaml() -> None: + config = load_thickness_sweep_config("configs/thickness_sweep.yaml") + + assert len(config.thickness_values_m) == 6 + assert config.thickness_values_m[0] == pytest.approx(0.0010) + assert config.thickness_values_m[-1] == pytest.approx(0.0020) + assert config.max_workers == 4