diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..a012473 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,81 @@ +# BodySimPy GitHub Copilot Instructions + +## Project identity + +BodySimPy is a Python-driven structural CAE workflow automation project built around a simplified automotive body structural surrogate. + +The project is intended to demonstrate engineering software architecture, structural mechanics, finite-element workflow automation, validation, uncertainty analysis, fatigue methods, parameter studies, reporting, and machine-learning surrogate modelling. + +Do not describe BodySimPy as a BMW project, proprietary body-in-white model, production vehicle model, or industrial validation study. + +## Engineering modelling rules + +- Use SI units internally unless a reporting layer explicitly converts units for presentation. +- Do not invent material properties, fatigue curves, manufacturing tolerances, load cases, geometry, experimental measurements, or solver results. +- Do not hardcode numerical FEA outputs that should come from CalculiX. +- Preserve the distinction between analytical reference solutions and finite-element results. +- Preserve the distinction between the simplified structural surrogate and a production automotive structure. +- Clearly state assumptions whenever a proposed implementation introduces a new physical assumption. + +## Structural configuration rules + +- Geometric dimensions must be physically positive. +- A rectangular hollow section is valid only when the wall thickness leaves a positive internal cavity. +- The physical constraint is: + + `2 * thickness < min(width, height)` + +- Do not silently clamp physically invalid geometry. +- Invalid geometry should be rejected explicitly. + +## Loading rules + +- `tip_force_n` is a signed quantity. +- A negative force is valid and represents the opposite loading direction. +- Do not add positivity validation to `tip_force_n` unless the project requirements explicitly change. + +## Material rules + +- Young's modulus and density must remain physically positive. +- Poisson's ratio must remain inside the limits already defined by the configuration model. +- Do not invent material-specific durability or fatigue data. + +## FEA rules + +- CalculiX solver outputs must be parsed from generated solver files. +- Do not replace real solver integration tests with mocked results when the test is specifically intended to validate CalculiX integration. +- Keep temporary/raw CalculiX outputs outside version control. +- Maintain isolated work directories when independent simulations execute concurrently. + +## Software architecture + +- Reusable production code belongs in `src/bodysimpy/`. +- Verification belongs in `tests/`. +- Exploratory notebooks belong in `notebooks/`. +- Scripts may orchestrate studies but should reuse package functionality rather than duplicate engineering equations. +- Keep solver-independent domain objects separate from solver-specific implementations. +- Prefer small, focused functions and dataclasses over monolithic workflows. + +## Testing workflow + +Prefer test-driven development for new behaviour: + +1. Define expected behaviour with a focused test. +2. Confirm the new test fails for the expected reason. +3. Implement the minimum correct behaviour. +4. Run the focused test. +5. Run the complete project quality gate. + +Do not change physically correct implementation behaviour merely to satisfy an AI-generated test. + +If an AI-generated test assumes behaviour that is not part of the BodySimPy requirements, flag that assumption instead of silently implementing it. + +## Quality gate + +Before considering a change complete, the project should pass: + +```bash +ruff format --check . +ruff check . +mypy src +python -m pytest \ No newline at end of file diff --git a/.github/prompts/configuration-edge-cases.prompt.md b/.github/prompts/configuration-edge-cases.prompt.md new file mode 100644 index 0000000..570f7e5 --- /dev/null +++ b/.github/prompts/configuration-edge-cases.prompt.md @@ -0,0 +1,89 @@ +# BodySimPy Configuration Edge-Case Review + +## Goal + +Identify useful additional edge-case unit tests for BodySimPy configuration validation. + +Do not modify any source or test files yet. + +Your first response must only propose candidate tests for human engineering review. + +## Relevant project files + +Review: + +- `src/bodysimpy/config/models.py` +- `tests/unit/test_config_models.py` +- `tests/unit/test_sweep_config.py` + +## Engineering constraints + +Preserve the existing BodySimPy requirements. + +In particular: + +- geometric dimensions must be physically positive; +- rectangular hollow-section geometry requires: + + `2 * thickness < min(width, height)` + +- `tip_force_n` is signed and negative force values are valid; +- do not invent a requirement that loads must be positive; +- configuration models intentionally reject unsupported extra fields; +- positive worker counts are required where parallel execution is configured; +- do not invent arbitrary maximum worker limits; +- do not silently clamp invalid engineering values; +- do not invent requirements for sorting parameter-sweep values unless existing code requires sorted values; +- preserve existing public behaviour unless a genuine defect is identified. + +## Task + +Inspect the existing models and tests. + +Identify gaps in boundary-value and invalid-input coverage. + +Consider categories such as: + +- exact physical boundary conditions; +- empty collections; +- zero values; +- negative values where physically invalid; +- signed values where negative values are intentionally valid; +- unsupported extra configuration fields; +- duplicate values; +- non-finite floating-point values; +- invalid worker counts; +- material-property boundaries. + +Do not assume every category necessarily requires a new test. + +## Required response format + +For every proposed candidate, provide: + +### Candidate N — descriptive test name + +**Target model/function:** +Name of the model or function. + +**Input condition:** +Exact edge condition being tested. + +**Expected behaviour:** +Pass or reject, including expected exception type when appropriate. + +**Reason:** +Why the test is useful. + +**Requirement support:** +State whether the expected behaviour is directly supported by existing BodySimPy code/requirements or requires a new engineering decision. + +## Important + +Do not edit files. + +Do not provide implementation changes yet. + +Do not invent physical requirements. + +If you identify an ambiguous requirement, explicitly mark it as ambiguous and ask for human engineering review. \ No newline at end of file diff --git a/docs/ai_assisted_development.md b/docs/ai_assisted_development.md new file mode 100644 index 0000000..7dd28de --- /dev/null +++ b/docs/ai_assisted_development.md @@ -0,0 +1,106 @@ +# AI-Assisted Development + +## Purpose + +BodySimPy uses AI-assisted development selectively to support software engineering tasks such as test generation, code review, documentation, refactoring suggestions, and workflow analysis. + +AI output is treated as a candidate engineering suggestion rather than authoritative code or engineering truth. + +The human developer remains responsible for validating physical assumptions, software behaviour, solver integration, tests, and final implementation decisions. + +--- + +## AID-001 — Configuration Validation Edge Cases + +### AI Output + +GitHub Copilot proposed 40 candidate configuration-validation tests covering geometry boundaries, material properties, signed loading, modal settings, stochastic configuration, mesh configuration, parameter sweeps, project configuration, strict-model behavior, and non-finite numerical values. + +The assistant distinguished between behavior already supported by the existing implementation and several ambiguous cases requiring a new engineering decision. + +Notably, the assistant correctly identified that negative `tip_force_n` values are intentionally valid and must not be rejected. + +The assistant also identified non-finite floating-point values (`NaN` and infinity) as an unresolved validation-policy question across multiple physical configuration fields. + +### Engineering Validation + +The candidate list was manually reviewed against BodySimPy's existing structural assumptions and software requirements. + +The review deliberately rejected broad implementation of all AI-generated candidates. Redundant tests that merely repeated equivalent direct field constraints were not selected solely because they were suggested by the AI assistant. + +Nine existing-contract tests were accepted for their boundary or regression value: + +- exact rectangular-hollow-section thickness boundary rejection; +- acceptance immediately inside the valid thickness boundary; +- both exact Poisson-ratio boundary rejections; +- preservation of valid negative signed tip force; +- rejection of an empty thickness sweep; +- preservation of unsorted sweep input; +- rejection of zero worker count; +- rejection of unsupported extra fields in a nested geometry configuration. + +Several suggestions were deferred because they would unnecessarily duplicate existing validation or would establish requirements not yet justified by the project. + +The non-finite-number suggestions were treated separately because they exposed a genuine engineering-policy question rather than an existing implementation requirement. + +A human engineering decision was made that physical numerical configuration values used by structural analysis and simulation workflows must be finite. `NaN`, positive infinity, and negative infinity are therefore considered invalid engineering configuration values. + +This finite-number policy will be introduced test-first before any production implementation is changed. + +## Validation Policy + +Every significant AI-assisted change follows this workflow: + +1. Define a bounded engineering or software task. +2. Provide relevant repository context and constraints. +3. Record the prompt used for the task. +4. Review the generated suggestions individually. +5. Reject suggestions that introduce unsupported physical or software assumptions. +6. Modify partially correct suggestions when necessary. +7. Implement only validated changes. +8. Run the complete BodySimPy quality gate. +9. Record the final accepted result. + +The standard verification commands are: + +### Final Result + +The AI-assisted review produced two classes of improvements. + +First, nine high-value regression and boundary tests were selected from the larger AI-generated candidate set after human engineering review. These tests protect existing requirements including signed loading, geometric boundary behaviour, parameter-sweep semantics, and strict nested configuration validation. + +Second, the AI review surfaced ambiguity around non-finite floating-point values. A human engineering decision established that physical numerical configuration values must be finite. + +Test-first validation showed that infinite Young's modulus, infinite signed load, infinite stochastic standard deviation, and infinite thickness-sweep entries were still accepted by the existing configuration layer, while some geometry cases were already rejected by existing constraints. + +The production configuration policy was then changed centrally through the shared Pydantic model configuration rather than by adding duplicated field-specific validators. + +The final policy preserves finite negative signed loads while rejecting NaN and positive/negative infinity. + +### Human Validation Decisions + +Accepted: +- preserve signed negative finite loads; +- enforce the exact rectangular-hollow-section geometric boundary; +- preserve unsorted sweep input; +- reject zero worker counts; +- reject unsupported nested fields; +- enforce finite numerical engineering configuration values. + +Rejected or deferred: +- redundant tests that simply duplicated equivalent existing field constraints; +- arbitrary maximum worker limits; +- automatic sweep sorting; +- silent input clamping; +- unsupported material or physical assumptions; +- unrelated project-name policy changes. + +### Verification + +The accepted implementation was verified using: + +```text +ruff format --check . +ruff check . +mypy src +python -m pytest \ No newline at end of file diff --git a/src/bodysimpy/config/models.py b/src/bodysimpy/config/models.py index 5d7e623..6522732 100644 --- a/src/bodysimpy/config/models.py +++ b/src/bodysimpy/config/models.py @@ -9,6 +9,7 @@ class StrictModel(BaseModel): model_config = ConfigDict( extra="forbid", frozen=True, + allow_inf_nan=False, ) diff --git a/tests/unit/test_config_models.py b/tests/unit/test_config_models.py index 6622d55..9323d93 100644 --- a/tests/unit/test_config_models.py +++ b/tests/unit/test_config_models.py @@ -65,3 +65,152 @@ def test_configuration_rejects_unknown_fields() -> None: with pytest.raises(ValidationError): SimulationConfig.model_validate(data) + + +def test_geometry_rejects_thickness_equal_to_min_half_dimension() -> None: + data = valid_configuration() + geometry = data["geometry"] + assert isinstance(geometry, dict) + + # 2 * thickness == min(width, height) should be rejected (strict inequality required) + geometry["width_m"] = 0.080 + geometry["height_m"] = 0.040 + geometry["thickness_m"] = 0.020 # 2 * 0.020 == 0.040 (min of dimensions) + + with pytest.raises(ValidationError): + SimulationConfig.model_validate(data) + + +def test_geometry_accepts_thickness_just_below_min_half_dimension() -> None: + data = valid_configuration() + geometry = data["geometry"] + assert isinstance(geometry, dict) + + # 2 * thickness < min(width, height) should be accepted + geometry["width_m"] = 0.080 + geometry["height_m"] = 0.040 + geometry["thickness_m"] = 0.01999 # just below 0.020 + + config = SimulationConfig.model_validate(data) + assert config.geometry.thickness_m == pytest.approx(0.01999) + + +def test_material_rejects_poisson_ratio_equal_to_minus_one() -> None: + data = valid_configuration() + material = data["material"] + assert isinstance(material, dict) + + # Poisson's ratio at lower boundary should be rejected (strict > -1.0) + material["poisson_ratio"] = -1.0 + + with pytest.raises(ValidationError): + SimulationConfig.model_validate(data) + + +def test_material_rejects_poisson_ratio_equal_to_half() -> None: + data = valid_configuration() + material = data["material"] + assert isinstance(material, dict) + + # Poisson's ratio at upper boundary should be rejected (strict < 0.5) + material["poisson_ratio"] = 0.5 + + with pytest.raises(ValidationError): + SimulationConfig.model_validate(data) + + +def test_loading_accepts_negative_tip_force() -> None: + data = valid_configuration() + loading = data["loading"] + assert isinstance(loading, dict) + + # Negative tip force should be accepted (signed quantity, valid opposite direction) + loading["tip_force_n"] = -1000.0 + + config = SimulationConfig.model_validate(data) + assert config.loading.tip_force_n == pytest.approx(-1000.0) + + +def test_simulation_rejects_extra_field_in_geometry() -> None: + data = valid_configuration() + geometry = data["geometry"] + assert isinstance(geometry, dict) + + # Extra field in nested model should be rejected + geometry["material_id"] = "steel_a" + + with pytest.raises(ValidationError): + SimulationConfig.model_validate(data) + + +def test_geometry_rejects_infinite_thickness() -> None: + data = valid_configuration() + geometry = data["geometry"] + assert isinstance(geometry, dict) + + # Infinite thickness is not a valid physical value + geometry["thickness_m"] = float("inf") + + with pytest.raises(ValidationError): + SimulationConfig.model_validate(data) + + +def test_geometry_rejects_nan_thickness() -> None: + data = valid_configuration() + geometry = data["geometry"] + assert isinstance(geometry, dict) + + # NaN thickness is not a valid physical value + geometry["thickness_m"] = float("nan") + + with pytest.raises(ValidationError): + SimulationConfig.model_validate(data) + + +def test_material_rejects_infinite_youngs_modulus() -> None: + data = valid_configuration() + material = data["material"] + assert isinstance(material, dict) + + # Infinite stiffness is not a valid physical value + material["youngs_modulus_pa"] = float("inf") + + with pytest.raises(ValidationError): + SimulationConfig.model_validate(data) + + +def test_loading_rejects_infinite_tip_force() -> None: + data = valid_configuration() + loading = data["loading"] + assert isinstance(loading, dict) + + # Infinite force is not a valid physical value (even though signed) + loading["tip_force_n"] = float("inf") + + with pytest.raises(ValidationError): + SimulationConfig.model_validate(data) + + +def test_normal_distribution_rejects_infinite_std_dev() -> None: + data = valid_configuration() + stochastic = { + "samples": 1000, + "seed": 42, + "stress_threshold_pa": 100e6, + "thickness_m": { + "mean": 0.0015, + "standard_deviation": float("inf"), + }, + "youngs_modulus_pa": { + "mean": 210e9, + "standard_deviation": 1e9, + }, + "tip_force_n": { + "mean": 1000.0, + "standard_deviation": 100.0, + }, + } + data["stochastic"] = stochastic + + with pytest.raises(ValidationError): + SimulationConfig.model_validate(data) diff --git a/tests/unit/test_sweep_config.py b/tests/unit/test_sweep_config.py index 86f5f91..2e1bc61 100644 --- a/tests/unit/test_sweep_config.py +++ b/tests/unit/test_sweep_config.py @@ -67,3 +67,57 @@ def test_load_thickness_sweep_yaml() -> None: 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 + + +def test_sweep_rejects_empty_thickness_values() -> None: + with pytest.raises(ValidationError): + ThicknessSweepConfig.model_validate( + { + "base_config": "configs/baseline_crossmember.yaml", + "thickness_values_m": [], + "output_csv": "docs/validation/thickness_sweep.csv", + "max_workers": 1, + } + ) + + +def test_sweep_accepts_unsorted_thickness_values() -> None: + config = ThicknessSweepConfig.model_validate( + { + "base_config": "configs/baseline_crossmember.yaml", + "thickness_values_m": [ + 0.0020, + 0.0010, + 0.0015, + ], + "output_csv": "docs/validation/thickness_sweep.csv", + "max_workers": 1, + } + ) + + # Values should be accepted as-is without being sorted + assert config.thickness_values_m == pytest.approx((0.0020, 0.0010, 0.0015)) + + +def test_sweep_rejects_zero_max_workers() -> None: + with pytest.raises(ValidationError): + ThicknessSweepConfig.model_validate( + { + "base_config": "configs/baseline_crossmember.yaml", + "thickness_values_m": [0.0010], + "output_csv": "docs/validation/thickness_sweep.csv", + "max_workers": 0, + } + ) + + +def test_sweep_rejects_infinite_thickness_value() -> None: + with pytest.raises(ValidationError): + ThicknessSweepConfig.model_validate( + { + "base_config": "configs/baseline_crossmember.yaml", + "thickness_values_m": [0.0010, float("inf"), 0.0020], + "output_csv": "docs/validation/thickness_sweep.csv", + "max_workers": 1, + } + )