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
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ dependencies = [
"PyYAML>=6.0",
"pydantic>=2.7",
"typer>=0.12",
"rich>=13.7"
"reportlab>=4.2",
"python-pptx>=1.0",
"rich>=13.7",
]

[project.optional-dependencies]
Expand All @@ -33,6 +35,7 @@ dev = [
"types-PyYAML",
"scipy-stubs",
"pandas-stubs",
"types-reportlab",
]

ml = [
Expand Down
Binary file added reports/BodySimPy_Engineering_Summary.pdf
Binary file not shown.
Binary file added reports/BodySimPy_Management_Summary.pptx
Binary file not shown.
25 changes: 25 additions & 0 deletions scripts/generate_reports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from bodysimpy.reporting.pdf_report import generate_engineering_summary_pdf
from bodysimpy.reporting.pptx_report import generate_management_summary_pptx
from bodysimpy.reporting.summary_data import load_reporting_summary


def main() -> None:
summary = load_reporting_summary()

generate_engineering_summary_pdf(
summary,
"reports/BodySimPy_Engineering_Summary.pdf",
)

generate_management_summary_pptx(
summary,
"reports/BodySimPy_Management_Summary.pptx",
)

print("Generated reports:")
print("reports/BodySimPy_Engineering_Summary.pdf")
print("reports/BodySimPy_Management_Summary.pptx")


if __name__ == "__main__":
main()
124 changes: 124 additions & 0 deletions src/bodysimpy/reporting/pdf_report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
from pathlib import Path

from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.platypus import (
Flowable,
Paragraph,
SimpleDocTemplate,
Spacer,
)

from bodysimpy.reporting.summary_data import ReportingSummary


def _value(
value: float | None,
suffix: str = "",
) -> str:
if value is None:
return "Not available"

return f"{value:.3f}{suffix}"


def generate_engineering_summary_pdf(
summary: ReportingSummary,
output_path: str | Path,
) -> None:
path = Path(output_path)

path.parent.mkdir(
parents=True,
exist_ok=True,
)

styles = getSampleStyleSheet()

story: list[Flowable] = []

doc = SimpleDocTemplate(
str(path),
pagesize=A4,
)

story.append(
Paragraph(
"BodySimPy Engineering Summary",
styles["Title"],
)
)

story.append(
Spacer(
1,
16,
)
)

sections = {
"Objective": (
"Automate structural CAE workflows for a simplified automotive "
"body crossmember surrogate using Python, analytical references, "
"CalculiX FEA, stochastic studies, fatigue assessment and ML surrogates."
),
"Method": (
"Validated configuration files generate structural models. "
"Analytical beam theory, CalculiX static/modal analysis and "
"Python post-processing are used to quantify response behavior."
),
"Baseline Configuration": (
"Thin-walled rectangular hollow-section cantilever surrogate with "
"linear-elastic isotropic material and idealized transverse loading."
),
"Key Results": (
f"Final mesh displacement error: "
f"{_value(summary.baseline_deflection_error_percent, ' %')}<br/>"
f"Final mesh stress error: "
f"{_value(summary.baseline_stress_error_percent, ' %')}<br/>"
f"Mode-1 frequency: "
f"{_value(summary.mode_1_frequency_hz, ' Hz')}<br/>"
f"Observed stochastic stress exceedance over 350 MPa: "
f"{_value(summary.stress_exceedance_percent, ' %')}<br/>"
f"Best ML frequency MAPE: "
f"{_value(summary.best_ml_frequency_mape_percent, ' %')}"
),
"Sensitivity": (
"Wall thickness, section geometry, Young's modulus, density and "
"load are evaluated through automated sweeps and stochastic analysis."
),
"Risk": (
"The model is a simplified surrogate, not a production body-in-white. "
"Results depend on idealized boundary conditions, beam assumptions, "
"generic material data and the sampled design domain."
),
"Engineering Recommendation": (
"Use BodySimPy as a reproducible CAE automation and validation "
"portfolio workflow. Extend shell modelling, fatigue realism and "
"solver-result extraction before claiming production-level accuracy."
),
}

for heading, text in sections.items():
story.append(
Paragraph(
heading,
styles["Heading2"],
)
)

story.append(
Paragraph(
text,
styles["BodyText"],
)
)

story.append(
Spacer(
1,
12,
)
)

doc.build(story)
133 changes: 133 additions & 0 deletions src/bodysimpy/reporting/pptx_report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
from pathlib import Path

from pptx import Presentation as create_presentation
from pptx.presentation import Presentation

from bodysimpy.reporting.summary_data import ReportingSummary


def _value(
value: float | None,
suffix: str = "",
) -> str:
if value is None:
return "Not available"

return f"{value:.3f}{suffix}"


def _add_title_slide(
presentation: Presentation,
) -> None:
slide = presentation.slides.add_slide(presentation.slide_layouts[0])

slide.shapes.title.text = "BodySimPy Management Summary"

slide.placeholders[1].text = "Python-driven CAE workflow automation"


def _add_bullets(
presentation: Presentation,
title: str,
bullets: list[str],
) -> None:
slide = presentation.slides.add_slide(presentation.slide_layouts[1])

slide.shapes.title.text = title

body = slide.placeholders[1].text_frame
body.clear()

for index, bullet in enumerate(bullets):
if index == 0:
paragraph = body.paragraphs[0]
else:
paragraph = body.add_paragraph()

paragraph.text = bullet
paragraph.level = 0


def generate_management_summary_pptx(
summary: ReportingSummary,
output_path: str | Path,
) -> None:
path = Path(output_path)

path.parent.mkdir(
parents=True,
exist_ok=True,
)

presentation = create_presentation()

_add_title_slide(presentation)

_add_bullets(
presentation,
"Objective",
[
("Automate structural CAE workflows using Python."),
("Validate FEA against analytical references."),
("Generate reusable engineering evidence for portfolio discussion."),
],
)

_add_bullets(
presentation,
"Method",
[
"Validated YAML configuration.",
"Analytical cantilever reference solution.",
"CalculiX static and modal simulations.",
("Stochastic, fatigue and ML surrogate studies."),
],
)

_add_bullets(
presentation,
"Key Results",
[
(f"Mesh displacement error: {_value(summary.baseline_deflection_error_percent, ' %')}"),
(f"Mesh stress error: {_value(summary.baseline_stress_error_percent, ' %')}"),
(f"Mode-1 frequency: {_value(summary.mode_1_frequency_hz, ' Hz')}"),
(f"Stress exceedance over 350 MPa: {_value(summary.stress_exceedance_percent, ' %')}"),
(f"Best ML frequency MAPE: {_value(summary.best_ml_frequency_mape_percent, ' %')}"),
],
)

_add_bullets(
presentation,
"Sensitivity",
[
("Wall-thickness influence on stress, deflection, mass and modal response."),
("Material stiffness and density influence on structural dynamics."),
("Stochastic uncertainty propagation for manufacturing, material and load inputs."),
],
)

_add_bullets(
presentation,
"Risk & Limitations",
[
("Simplified structural surrogate, not production body-in-white geometry."),
("Idealized boundary conditions and generic material assumptions."),
("ML surrogate is valid only inside the sampled engineering design space."),
],
)

_add_bullets(
presentation,
"Engineering Recommendation",
[
("Use BodySimPy as a reproducible CAE automation and validation workflow."),
("Use automated parameter studies to identify influential design variables."),
(
"Extend shell modelling, joint modelling "
"and durability fidelity before "
"production-style conclusions."
),
],
)

presentation.save(str(path))
58 changes: 58 additions & 0 deletions src/bodysimpy/reporting/summary_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from dataclasses import dataclass
from pathlib import Path

import pandas as pd


@dataclass(frozen=True, slots=True)
class ReportingSummary:
project_name: str
baseline_deflection_error_percent: float | None
baseline_stress_error_percent: float | None
mode_1_frequency_hz: float | None
stress_exceedance_percent: float | None
best_ml_frequency_mape_percent: float | None


def _read_csv_if_exists(path: Path) -> pd.DataFrame | None:
if not path.exists():
return None
return pd.read_csv(path)


def load_reporting_summary() -> ReportingSummary:
validation_dir = Path("docs/validation")

static_mesh = _read_csv_if_exists(validation_dir / "static_mesh_convergence.csv")
modal = _read_csv_if_exists(validation_dir / "modal_frequencies.csv")
stochastic = _read_csv_if_exists(validation_dir / "stochastic_samples.csv")
ml = _read_csv_if_exists(validation_dir / "ml_sample_efficiency.csv")

baseline_deflection_error = None
baseline_stress_error = None

if static_mesh is not None and not static_mesh.empty:
final_row = static_mesh.iloc[-1]
baseline_deflection_error = float(final_row["deflection_error_percent"])
baseline_stress_error = float(final_row["stress_error_percent"])

mode_1_frequency = None
if modal is not None and not modal.empty:
mode_1_frequency = float(modal.iloc[0]["frequency_hz"])

stress_exceedance = None
if stochastic is not None and not stochastic.empty:
stress_exceedance = float((stochastic["max_stress_mpa"] > 350.0).mean() * 100.0)

best_ml_frequency_mape = None
if ml is not None and not ml.empty:
best_ml_frequency_mape = float(ml["frequency_mape_percent"].min())

return ReportingSummary(
project_name="BodySimPy",
baseline_deflection_error_percent=baseline_deflection_error,
baseline_stress_error_percent=baseline_stress_error,
mode_1_frequency_hz=mode_1_frequency,
stress_exceedance_percent=stress_exceedance,
best_ml_frequency_mape_percent=best_ml_frequency_mape,
)