diff --git a/pyproject.toml b/pyproject.toml index fed3283..d6e42e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] @@ -33,6 +35,7 @@ dev = [ "types-PyYAML", "scipy-stubs", "pandas-stubs", + "types-reportlab", ] ml = [ diff --git a/reports/BodySimPy_Engineering_Summary.pdf b/reports/BodySimPy_Engineering_Summary.pdf new file mode 100644 index 0000000..3b044a8 Binary files /dev/null and b/reports/BodySimPy_Engineering_Summary.pdf differ diff --git a/reports/BodySimPy_Management_Summary.pptx b/reports/BodySimPy_Management_Summary.pptx new file mode 100644 index 0000000..2638302 Binary files /dev/null and b/reports/BodySimPy_Management_Summary.pptx differ diff --git a/scripts/generate_reports.py b/scripts/generate_reports.py new file mode 100644 index 0000000..82bfd38 --- /dev/null +++ b/scripts/generate_reports.py @@ -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() diff --git a/src/bodysimpy/reporting/pdf_report.py b/src/bodysimpy/reporting/pdf_report.py new file mode 100644 index 0000000..adfc584 --- /dev/null +++ b/src/bodysimpy/reporting/pdf_report.py @@ -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, ' %')}
" + f"Final mesh stress error: " + f"{_value(summary.baseline_stress_error_percent, ' %')}
" + f"Mode-1 frequency: " + f"{_value(summary.mode_1_frequency_hz, ' Hz')}
" + f"Observed stochastic stress exceedance over 350 MPa: " + f"{_value(summary.stress_exceedance_percent, ' %')}
" + 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) diff --git a/src/bodysimpy/reporting/pptx_report.py b/src/bodysimpy/reporting/pptx_report.py new file mode 100644 index 0000000..9744257 --- /dev/null +++ b/src/bodysimpy/reporting/pptx_report.py @@ -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)) diff --git a/src/bodysimpy/reporting/summary_data.py b/src/bodysimpy/reporting/summary_data.py new file mode 100644 index 0000000..ca56352 --- /dev/null +++ b/src/bodysimpy/reporting/summary_data.py @@ -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, + )