Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions configs/thickness_sweep.yaml
Original file line number Diff line number Diff line change
@@ -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
Binary file added docs/figures/thickness_vs_stress.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions docs/validation/thickness_sweep.csv
Original file line number Diff line number Diff line change
@@ -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
32 changes: 32 additions & 0 deletions scripts/plot_thickness_sweep.py
Original file line number Diff line number Diff line change
@@ -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()
149 changes: 149 additions & 0 deletions src/bodysimpy/analysis/parameter_sweep.py
Original file line number Diff line number Diff line change
@@ -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,
]
)
26 changes: 26 additions & 0 deletions src/bodysimpy/cli.py
Original file line number Diff line number Diff line change
@@ -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.",
Expand All @@ -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()
24 changes: 23 additions & 1 deletion src/bodysimpy/config/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
22 changes: 22 additions & 0 deletions src/bodysimpy/config/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
39 changes: 39 additions & 0 deletions src/bodysimpy/workflows/parameter_sweep.py
Original file line number Diff line number Diff line change
@@ -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
Loading