From da388f3085a7565af057c184304de8dcb1cc98e9 Mon Sep 17 00:00:00 2001 From: Allamaprabhu Ani Date: Mon, 12 Oct 2026 09:39:33 +0100 Subject: [PATCH] Remove the experimental learned-damage route The learned-damage route is not part of the released solver scope, so it is removed together with its configuration, documentation and tests. The classical damage solve is now the only damage-subproblem route. Removed: - phast.learned_damage and the phast.training package - examples/learned_damage and docs/user_guide/learned_damage.md - tests for the learned-damage controller, step context and mesh-graph adapter - SolverConfig and SolverSettings fields damage_update, damage_predictor, damage_checkpoint, damage_predictor_options, damage_residual_rtol, damage_residual_atol, damage_bound_tolerance and damage_fallback, with their validation, schema and REFERENCE.yaml entries - StaggeredSolver.set_damage_predictor, clear_damage_predictor, the per-step context builder, the route-report predictor fields and the AIBridge class - FEMOperators.compute_physics_loss, which had no remaining callers - the learned-damage guidance in phast doctor The modular-FEM tutorial is renamed to 03_modular_fem.md ("Modular fracture problems") and keeps only its finite-element content. The regenerated phast.schema.json also picks up the rational_at2 degradation and stress_degradation entries that the checked-in file was missing. --- DOCUMENTATION_STYLE.md | 16 +- README.md | 1 - configs/REFERENCE.yaml | 8 - configs/phast.schema.json | 70 +-- docs/index.md | 3 +- docs/tutorial/03_modular_fem.md | 94 +++ .../03_modular_fem_and_learned_damage.md | 164 ------ .../05_heterogeneous_material_fields.md | 4 +- docs/tutorial/06_first_research_study.md | 4 +- docs/tutorial/index.md | 6 +- docs/tutorial/notebook_layered_dcb.ipynb | 2 +- docs/user_guide/capability_matrix.md | 2 - docs/user_guide/learned_damage.md | 214 ------- examples/learned_damage/README.md | 146 ----- examples/learned_damage/__init__.py | 13 - .../learned_damage/architectures/__init__.py | 8 - .../architectures/mesh_graph_net.py | 424 -------------- .../learned_damage/architectures/template.py | 141 ----- examples/learned_damage/predictor_plugin.py | 75 --- .../dynamic_sent/config.yaml | 2 +- .../quasistatic_sent/config.yaml | 2 +- src/phast/__init__.py | 8 - src/phast/config/config.py | 18 - src/phast/config/config_validation.py | 34 -- src/phast/core/fem_operators.py | 92 --- src/phast/learned_damage.py | 550 ------------------ src/phast/solvers/damage_solver.py | 4 +- src/phast/solvers/staggered_solver.py | 230 +------- src/phast/training/__init__.py | 35 -- src/phast/training/losses.py | 161 ----- src/phast/training/self_training.py | 177 ------ src/phast/utils/doctor.py | 2 - src/phast/utils/io_utils.py | 10 +- src/phast/workflow/execution.py | 4 - tests/test_damage_step_context_fem_helpers.py | 122 ---- tests/test_learned_damage.py | 152 ----- tests/test_mesh_graph_net_adapter.py | 251 -------- tests/test_standard_workflow.py | 6 +- 38 files changed, 149 insertions(+), 3106 deletions(-) create mode 100644 docs/tutorial/03_modular_fem.md delete mode 100644 docs/tutorial/03_modular_fem_and_learned_damage.md delete mode 100644 docs/user_guide/learned_damage.md delete mode 100644 examples/learned_damage/README.md delete mode 100644 examples/learned_damage/__init__.py delete mode 100644 examples/learned_damage/architectures/__init__.py delete mode 100644 examples/learned_damage/architectures/mesh_graph_net.py delete mode 100644 examples/learned_damage/architectures/template.py delete mode 100644 examples/learned_damage/predictor_plugin.py delete mode 100644 src/phast/learned_damage.py delete mode 100644 src/phast/training/__init__.py delete mode 100644 src/phast/training/losses.py delete mode 100644 src/phast/training/self_training.py delete mode 100644 tests/test_damage_step_context_fem_helpers.py delete mode 100644 tests/test_learned_damage.py delete mode 100644 tests/test_mesh_graph_net_adapter.py diff --git a/DOCUMENTATION_STYLE.md b/DOCUMENTATION_STYLE.md index e1367bd3e..cfe9fc175 100644 --- a/DOCUMENTATION_STYLE.md +++ b/DOCUMENTATION_STYLE.md @@ -340,8 +340,6 @@ Prefer a more specific term whenever one exists. | AT1 bound treatment | projected conjugate gradients, then projected CG | | Explicit route | explicit dynamics with the integrator named | | Quasi-static route | implicit equilibrium solve within the staggered scheme | -| Learned proposal | learned damage proposal followed by the documented checks | -| Learned replacement | audited learned damage replacement with classical fallback | | Subcycling | damage subcycling with interval `$N$` | | Fracture toughness | `$G_c$`, with units defined | | Regularisation length | `$\ell_0$`, with units defined | @@ -384,7 +382,7 @@ When comparing performance, report: Do not describe one backend as universally fastest. -## 10. Differentiability and learned models +## 10. Differentiability Avoid unqualified statements that PhAST differentiates every fracture calculation. Prefer: @@ -393,18 +391,6 @@ calculation. Prefer: > updates, bounds, active-set changes, remeshing decisions, and other > non-smooth operations require case-specific interpretation. -For learned damage updates, state who remains responsible for the physical -solution: - -- A learned proposal supplies an initial damage estimate. -- PhAST checks bounds, irreversibility, phase-field boundary conditions, and - the projected residual where the selected route provides those checks. -- The classical damage solver remains available as the fallback. -- No trained model is implied unless a checkpoint is distributed and linked. - -Do not call a learned model faster without measuring its complete inference, -checking, rejection, and fallback cost against the matched classical solve. - ## 11. Commands and configurations - Copy commands from the current CLI help. diff --git a/README.md b/README.md index ff950dd86..e5065d0ca 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,6 @@ Browse the full [example gallery](docs/example-gallery.md) for the complete list | Post-process simulation data | `phast.load_result(path)` | [Public API Reference](docs/user_guide/public_api_reference.md) | | Review supported physics | Capability matrix | [Capability Matrix](docs/user_guide/capability_matrix.md) | | Learn step-by-step setup | Tutorial notebook | [Problem Setup Walkthrough](docs/tutorial/problem_setup_walkthrough.ipynb) | -| Add an audited learned damage model | Predictor plug-in protocol | [Modular FEM and Learned Damage](docs/tutorial/03_modular_fem_and_learned_damage.md) | | Learn elementwise `E(x)` and `Gc(x)` fields | Script-contract teaching example | [Heterogeneous Material Fields](docs/tutorial/05_heterogeneous_material_fields.md) | | Diagnose failed runs | Troubleshooting guide | [Troubleshooting](docs/troubleshooting.md) | diff --git a/configs/REFERENCE.yaml b/configs/REFERENCE.yaml index 6594a436c..2239525bc 100644 --- a/configs/REFERENCE.yaml +++ b/configs/REFERENCE.yaml @@ -386,14 +386,6 @@ solver: # softmax_H_beta: null # float or null # H_update_method: hard_max # str, enum: hard_max|softmax|smooth_max|log_smooth|custom_subgrad # enable_damage: true # bool - # damage_update: classical # str, enum: classical|learned_proposal|learned_replacement - # damage_predictor: null # str or null, module:factory used by learned routes - # damage_checkpoint: null # str or null, interpreted by the predictor factory - # damage_predictor_options: {} # mapping, architecture-specific adapter options - # damage_residual_rtol: 0.001 # float, range: [0.0, +inf] - # damage_residual_atol: 1e-08 # float, range: [0.0, +inf] - # damage_bound_tolerance: 1e-08 # float, range: [0.0, +inf] - # damage_fallback: true # bool, use classical damage solve after rejection # fail_on_mechanics_nonconvergence: true # bool # adaptive_dt: false # bool # adaptive_dt_d_threshold: 0.01 # float diff --git a/configs/phast.schema.json b/configs/phast.schema.json index 8b853691f..6a6dac9d3 100644 --- a/configs/phast.schema.json +++ b/configs/phast.schema.json @@ -152,7 +152,14 @@ "enum": [ "standard", "cubic", - "rational" + "rational", + "rational_at2" + ] + }, + "stress_degradation": { + "enum": [ + "split", + "full" ] }, "driving_force": { @@ -308,7 +315,19 @@ "enum": [ "standard", "cubic", - "rational" + "rational", + "rational_at2" + ] + }, + "stress_degradation": { + "type": [ + "string", + "null" + ], + "default": null, + "enum": [ + "split", + "full" ] }, "kinematics": { @@ -622,53 +641,6 @@ "type": "boolean", "default": true }, - "damage_update": { - "type": "string", - "default": "classical", - "enum": [ - "classical", - "learned_proposal", - "learned_replacement" - ] - }, - "damage_predictor": { - "type": [ - "string", - "null" - ], - "default": null - }, - "damage_checkpoint": { - "type": [ - "string", - "null" - ], - "default": null - }, - "damage_predictor_options": { - "type": "object", - "additionalProperties": true, - "default": {} - }, - "damage_residual_rtol": { - "type": "number", - "default": 0.001, - "minimum": 0.0 - }, - "damage_residual_atol": { - "type": "number", - "default": 1e-08, - "minimum": 0.0 - }, - "damage_bound_tolerance": { - "type": "number", - "default": 1e-08, - "minimum": 0.0 - }, - "damage_fallback": { - "type": "boolean", - "default": true - }, "fail_on_mechanics_nonconvergence": { "type": "boolean", "default": true diff --git a/docs/index.md b/docs/index.md index 8d5081f78..7d0c12d75 100644 --- a/docs/index.md +++ b/docs/index.md @@ -148,7 +148,7 @@ tutorial/notebook_dynamic_sent tutorial/notebook_setup tutorial/notebook_mesh_resolution tutorial/notebook_retained_results -tutorial/03_modular_fem_and_learned_damage +tutorial/03_modular_fem tutorial/04_exploration_experiments tutorial/05_heterogeneous_material_fields tutorial/06_first_research_study @@ -176,7 +176,6 @@ user_guide/setup_problems user_guide/python_api user_guide/yaml_workflow user_guide/results_visualization -user_guide/learned_damage performance-reproducibility paper-reproduction ``` diff --git a/docs/tutorial/03_modular_fem.md b/docs/tutorial/03_modular_fem.md new file mode 100644 index 000000000..9277b56cd --- /dev/null +++ b/docs/tutorial/03_modular_fem.md @@ -0,0 +1,94 @@ +# Modular fracture problems + +This tutorial describes how the main components of a phase-field fracture +problem fit together in PhAST without assuming prior knowledge of the +codebase. + +| Lesson item | Scope | +|---|---| +| Prerequisites | Complete the installation checks and read the phase-field primer. | +| Route | Quasi-static staggered phase-field fracture through a checked-in YAML configuration. | +| Hardware | CPU is sufficient for configuration inspection and the bounded example route. | +| Verification | Inspect the selected route, result manifest, and convergence history. | + +## 1. The finite-element problem + +A runnable problem combines: + +1. A mesh defining geometry, nodes, elements, and physical boundary groups. +2. A material defining elasticity, fracture resistance, regularization length, + density, crack-density model, degradation law, and energy split. +3. Initial conditions defining displacement, velocity, damage, and any initial + crack. +4. Boundary conditions constraining mechanical or phase-field degrees of + freedom. +5. Loading prescribing the time-dependent displacement or force history. +6. Solver settings choosing integration, linear algebra, and the damage + subproblem. +7. Output settings selecting fields and sampling frequency. + +Keep reusable configurations under `configs/`. Schema, manifest, contract, and +template files are machine-readable specifications rather than simulations. + +## 2. Distinguish the fracture-model choices + +- `pf_model: AT1` or `AT2` selects the crack-density functional. +- `energy_split` selects the elastic-energy decomposition. Implementations + include `spectral`, `spectral_stress`, + `spectral_plane_stress_condensed`, `isotropic`, `amor`, and `star_convex`. +- `degradation_type` selects the stiffness-degradation family. + +The supported coupled AT1/AT2 route uses `degradation_type: standard`. +PF-CZM is experimental and constructs its rational law from its own +parameters. Although the material layer contains additional degradation +functions, PhAST does not present cubic or rational degradation as supported +coupled AT1/AT2 damage solves. + +## 3. Begin from a complete problem + +Mesh groups, loading controls, and output requests are geometry-specific. Use +the checked-in Miehe tension configuration rather than a fragment containing +placeholder paths: + +```bash +python -m phast explain-config examples/quasistatic/miehe_tension/config.yaml +python -m phast run examples/quasistatic/miehe_tension/config.yaml --validate-only +python -m phast run examples/quasistatic/miehe_tension/config.yaml \ + --num_steps 5 --output_dir runs/miehe_tension_five_step +``` + +The five-step calculation is a bounded workflow exercise, not a crack-growth +validation result. Read the example README before undertaking the full +calculation; it records the retained runtime and evidence boundary. + +## 4. How automatic route selection works + +Automatic device and sparse-backend settings select a deterministic route from +the backends available in the current environment. This is a portability +policy, not a universal fastest-solver guarantee. Performance depends on +problem size, sparsity, hardware, installed libraries, and transfer costs. + +At startup and completion, PhAST reports the device, precision, element type, +mechanics solver, configured and resolved sparse backend, fracture model, +energy split, history update, damage-bound treatment, and preconditioner. +Preserve this report with benchmark results. + +```bash +python -m phast doctor +``` + +## 5. A defensible student workflow + +1. Run an existing example without changing its formulation. +2. Modify one component at a time: mesh, material, loading, or energy split. +3. Examine the route report and retained displacement, damage, and history. +4. Compare each modified run with the unmodified reference calculation. + +Students and developers are invited to propose tutorials and examples. If an +installation step, configuration field, or solver message is unclear, open a +GitHub issue with the configuration, environment report, and minimal +reproduction. + +Continue with [Heterogeneous material fields](05_heterogeneous_material_fields.md) +for an element-ordered material-field teaching problem, or return to the +[example gallery](../example-gallery.md) for complete workflows. diff --git a/docs/tutorial/03_modular_fem_and_learned_damage.md b/docs/tutorial/03_modular_fem_and_learned_damage.md deleted file mode 100644 index d2e28bf2a..000000000 --- a/docs/tutorial/03_modular_fem_and_learned_damage.md +++ /dev/null @@ -1,164 +0,0 @@ -# Modular fracture problems and learned damage updates - -This tutorial describes how the main components of a phase-field fracture -problem fit together in PhAST. It also introduces the experimental -learned-damage plug-in without assuming prior knowledge of the codebase. - -| Lesson item | Scope | -|---|---| -| Prerequisites | Complete the installation checks and read the phase-field primer. | -| Classical route | Quasi-static staggered phase-field fracture through a checked-in YAML configuration. | -| Experimental route | A user-supplied learned proposal or replacement for the damage subproblem only. | -| Hardware | CPU is sufficient for configuration inspection and the bounded example route. | -| Verification | Inspect the selected route, result manifest, convergence history, and learned acceptance/fallback telemetry. | - -## 1. The finite-element problem - -A runnable problem combines: - -1. A mesh defining geometry, nodes, elements, and physical boundary groups. -2. A material defining elasticity, fracture resistance, regularization length, - density, crack-density model, degradation law, and energy split. -3. Initial conditions defining displacement, velocity, damage, and any initial - crack. -4. Boundary conditions constraining mechanical or phase-field degrees of - freedom. -5. Loading prescribing the time-dependent displacement or force history. -6. Solver settings choosing integration, linear algebra, and damage update. -7. Output settings selecting fields and sampling frequency. - -Keep reusable configurations under `configs/`. Schema, manifest, contract, and -template files are machine-readable specifications rather than simulations. - -## 2. Distinguish the fracture-model choices - -- `pf_model: AT1` or `AT2` selects the crack-density functional. -- `energy_split` selects the elastic-energy decomposition. Implementations - include `spectral`, `spectral_stress`, - `spectral_plane_stress_condensed`, `isotropic`, `amor`, and `star_convex`. -- `degradation_type` selects the stiffness-degradation family. - -The supported coupled AT1/AT2 route uses `degradation_type: standard`. -PF-CZM is experimental and constructs its rational law from its own -parameters. Although the material layer contains additional degradation -functions, PhAST does not present cubic or rational degradation as supported -coupled AT1/AT2 damage solves. - -## 3. Begin from a complete classical problem - -Mesh groups, loading controls, and output requests are geometry-specific. Use -the checked-in Miehe tension configuration rather than a fragment containing -placeholder paths: - -```bash -python -m phast explain-config examples/quasistatic/miehe_tension/config.yaml -python -m phast run examples/quasistatic/miehe_tension/config.yaml --validate-only -python -m phast run examples/quasistatic/miehe_tension/config.yaml \ - --num_steps 5 --output_dir runs/miehe_tension_five_step -``` - -The five-step calculation is a bounded workflow exercise, not a crack-growth -validation result. Read the example README before undertaking the full -calculation; it records the retained runtime and evidence boundary. - -## 4. How automatic route selection works - -Automatic device and sparse-backend settings select a deterministic route from -the backends available in the current environment. This is a portability -policy, not a universal fastest-solver guarantee. Performance depends on -problem size, sparsity, hardware, installed libraries, and transfer costs. - -At startup and completion, PhAST reports the device, configured and resolved -sparse backend, damage-update mode, predictor, and learned-update -acceptance/fallback counts. Preserve this report with benchmark results. - -```bash -python -m phast doctor -``` - -## 5. Use a learned model as a proposal - -PhAST does not distribute a generally applicable trained damage checkpoint. -The following block documents the plug-in contract and is not independently -runnable until the user supplies a compatible factory and checkpoint. - -```yaml -solver: - damage_update: learned_proposal - damage_predictor: examples.learned_damage.predictor_plugin:create_predictor - damage_checkpoint: path/to/model.pt - damage_predictor_options: - representation: damage_increment -``` - -The predictor receives a `DamageStepContext` containing coordinates, -connectivity, displacement, velocity, element and nodal history, previous -damage, material metadata, formulation choices, time, and load factor. The -reference TorchScript adapter uses: - -```text -[x, y, history field, previous damage, displacement x, displacement y] -``` - -This six-column matrix is only the reference adapter. The public protocol does -not impose one architecture or tensor layout. A plug-in can access raw -coordinates and connectivity, call `context.graph_edge_index()`, construct -node-element incidence operators, retain temporal state, project fields to a -regular grid, and apply checkpoint-specific normalization. This accommodates -graph, coordinate-conditioned, temporal, grid-operator, and reduced-basis -research without placing Paper-specific models in the solver core. - -A `DamagePrediction` declares `representation="damage"` or -`representation="damage_increment"`. The latter is added to the previous -accepted damage before projection and audit. - -PhAST projects the proposal onto damage bounds, irreversibility, and prescribed -phase-field values, then uses it as the initial guess for the classical damage -solve. The converged classical update remains authoritative. - -## 6. Experimental learned replacement - -This mode still replaces only the damage-subproblem update. Mechanics, history -construction, constraints, audit checks, and the classical fallback remain -part of the PhAST workflow. - -```yaml -solver: - damage_update: learned_replacement - damage_predictor: examples.learned_damage.predictor_plugin:create_predictor - damage_checkpoint: path/to/model.pt - damage_residual_rtol: 1.0e-3 - damage_residual_atol: 1.0e-8 - damage_bound_tolerance: 1.0e-8 - damage_fallback: true -``` - -Replacement is accepted only after shape, finite-value, damage-bound, -irreversibility, phase-field Dirichlet, and projected-residual checks. If a -check fails, the default route is an exact classical fallback. Disabling -fallback turns rejection into an error and should be reserved for debugging. - -These safeguards are not evidence that a learned model is accurate or -generalizes. Scientific claims require independent comparisons against matched -finite-element trajectories and complete reporting of acceptance, rejection, -and fallback events. - -## 7. A defensible student workflow - -1. Run an existing classical example without changing its formulation. -2. Modify one component at a time: mesh, material, loading, or energy split. -3. Examine the route report and retained displacement, damage, and history. -4. Generate training data with the constitutive and discretization choices - intended at inference. -5. Introduce a learned model through `learned_proposal`. -6. Compare the corrected trajectory with an independent classical reference. -7. Study `learned_replacement` only after fallback reporting is understood. - -The plug-in is architecture-neutral. Students and developers are invited to -propose adapters, tutorials, and audited examples. If an installation step, -configuration field, or solver message is unclear, open a GitHub issue with -the configuration, environment report, and minimal reproduction. - -Continue with [Heterogeneous material fields](05_heterogeneous_material_fields.md) -for an element-ordered material-field teaching problem, or return to the -[example gallery](../example-gallery.md) for complete classical workflows. diff --git a/docs/tutorial/05_heterogeneous_material_fields.md b/docs/tutorial/05_heterogeneous_material_fields.md index e1aaef4e4..c282456f1 100644 --- a/docs/tutorial/05_heterogeneous_material_fields.md +++ b/docs/tutorial/05_heterogeneous_material_fields.md @@ -81,5 +81,5 @@ See the public [`examples/heterogeneous_fields/README.md`](https://github.com/CE for parameters, outputs, and adaptation guidance. Next, use the [mesh-resolution diagnostic](notebook_mesh_resolution.ipynb) to -interpret `h/l0`, or return to [Modular fracture problems](03_modular_fem_and_learned_damage.md) -before introducing a learned damage proposal. +interpret `h/l0`, or return to [Modular fracture problems](03_modular_fem.md) +to review how the problem components fit together. diff --git a/docs/tutorial/06_first_research_study.md b/docs/tutorial/06_first_research_study.md index 860aa93b1..765468d9e 100644 --- a/docs/tutorial/06_first_research_study.md +++ b/docs/tutorial/06_first_research_study.md @@ -101,7 +101,6 @@ choice, retained histories, and animation back to one public configuration. | Quasi-static fracture | `examples/quasistatic/notched_holed_plate/config.yaml` or the example identified as closest to the target problem | Requires convergence, mesh, parameter, and comparison evidence for a new claim. | | Explicit dynamic fracture | `examples/dynamic/B2_kalthoff_winkler/config.yaml` or another curated dynamic example | Requires stable time-step and output-cost assessment. | | Elementwise heterogeneous properties | `examples/heterogeneous_fields/parameters.yaml` with its script-contract runner | A bounded teaching route, not a general heterogeneous YAML adapter or validated microstructure study. | -| Learned damage proposal | The experimental interface described in [Modular FEM and learned damage](03_modular_fem_and_learned_damage.md) | Replaces only the damage proposal; no trained checkpoint is distributed and classical acceptance/fallback remains authoritative. | Do not construct a new configuration by combining options from unrelated examples unless the capability matrix and schema identify that combination as @@ -252,8 +251,7 @@ A suitable first milestone is: > mesh, solver, and evidence limitations. New constitutive laws, unsupported interface formulations, automatic image-to- -mesh pipelines, three-dimensional fracture, a new learned architecture, or a -new execution adapter are software-development projects. They should be scoped +mesh pipelines, three-dimensional fracture, or a new execution adapter are software-development projects. They should be scoped separately from the first modelling milestone. ## Requesting review or assistance diff --git a/docs/tutorial/index.md b/docs/tutorial/index.md index 2ed0d1c5b..ac87046f7 100644 --- a/docs/tutorial/index.md +++ b/docs/tutorial/index.md @@ -69,7 +69,7 @@ checkout, and prints the resolved commit before installation. | [Retained-results notebook](notebook_retained_results.ipynb) | 10 min | Inspect checked-in Miehe result evidence without rerunning the full calculation. | | [Python API](../user_guide/python_api.md) | 10-15 min | Author a model with `phast.Problem` and understand the fluent method map. | | [Visual glossary](02_visual_glossary.md) | 10 min | Read the picture-first guide to AT1/AT2, energy splits, and `l0`. | -| [Modular FEM and learned damage](03_modular_fem_and_learned_damage.md) | 20 min | Assemble geometry, material, boundary conditions, fracture choices, solver routes, and an audited learned-damage plug-in. | +| [Modular fracture problems](03_modular_fem.md) | 20 min | Assemble geometry, material, boundary conditions, fracture choices, and solver routes. | | [Heterogeneous material fields](05_heterogeneous_material_fields.md) | 15 min | Define element-ordered `E(x)` and `Gc(x)` arrays and solve a bounded AT2 damage teaching problem. | | [From tutorial to first research study](06_first_research_study.md) | 20 min | Convert a modelling question into a bounded PhAST study with explicit decisions, preflight checks, pilot execution, outputs, and validation evidence. | | [YAML workflow](../user_guide/yaml_workflow.md) | 10 min | Run a public declarative configuration and understand the standard result directory. | @@ -89,8 +89,8 @@ checkout, and prints the resolved commit before installation. 5. Inspect the completed run with `phast.load_result(...)`. 6. Build a small model with `phast.Problem`. 7. Read the [visual glossary](02_visual_glossary.md) if the terminology feels abstract. -8. Read [Modular FEM and learned damage](03_modular_fem_and_learned_damage.md) - before introducing a learned damage proposal. +8. Read [Modular fracture problems](03_modular_fem.md) + before changing one problem component at a time. 9. Run [Heterogeneous material fields](05_heterogeneous_material_fields.md) before adapting a segmented or multiphase material map. 10. Use [From tutorial to first research study](06_first_research_study.md) to diff --git a/docs/tutorial/notebook_layered_dcb.ipynb b/docs/tutorial/notebook_layered_dcb.ipynb index edb9821b9..8abeebd62 100644 --- a/docs/tutorial/notebook_layered_dcb.ipynb +++ b/docs/tutorial/notebook_layered_dcb.ipynb @@ -894,7 +894,7 @@ "| Result directory already exists | Restart and run all cells for a new study identifier. Do not overwrite another parameter choice. |\n", "| OneDrive reports conflicts | Finish writing before synchronisation elsewhere. HDF5 reduces file count but does not make concurrent writers safe. |\n", "\n", - "This example does not establish a general interface, plasticity, three-dimensional composite, dynamic multi-material, or learned-damage workflow. A neural predictor cannot be inserted into this particular adapter merely by changing its solver string. Check the [capability matrix](../user_guide/capability_matrix.md) before selecting another route.\n", + "This example does not establish a general interface, plasticity, three-dimensional composite, or dynamic multi-material workflow. Check the [capability matrix](../user_guide/capability_matrix.md) before selecting another route.\n", "\n", "The small-strain assumption, diffuse crack length relative to the circular region, elementwise boundary approximation, and confinement by the outer material affect interpretation. A convincing animation alone does not settle those modelling questions.\n", "\n", diff --git a/docs/user_guide/capability_matrix.md b/docs/user_guide/capability_matrix.md index ee1df5081..8c37e9bd6 100644 --- a/docs/user_guide/capability_matrix.md +++ b/docs/user_guide/capability_matrix.md @@ -42,8 +42,6 @@ see the [plasticity and interface beta workflow](../supported_workflows/plastici | Coupled brittle PF + cohesive elements | Beta | Staggered AT2 matrix damage can be combined with zero-thickness cohesive-interface delamination. Calibrated structural validation remains incomplete. | | PF-CZM | Beta | Wu PF-CZM is available through `pf_model: PFCZM` for nonlinear forward damage solves with tensile-strength-calibrated rational degradation and uniaxial tests. Structural crack-growth validation is incomplete, and PF-plasticity-cohesive coupling is not supported. | | Coupled PF + plasticity + cohesive interfaces | Unsupported | This combined formulation is not available through the documented solver interface. | -| Learned damage proposal | Experimental | A user-supplied predictor may provide a projected initial guess; the classical damage solve remains authoritative. No trained model is distributed. | -| Audited learned damage replacement | Experimental | A predicted damage state may replace one damage solve only after bound, irreversibility, phase-field boundary-condition, and projected-residual checks. Rejection uses the classical fallback by default. | | 3D fracture | Unsupported | The documented fracture element pathways are two-dimensional. | | P2 / Q8 / Q9 element primitives | Scaffold | Shape functions, quadrature, and single-element stiffness tests exist for higher-order families; global solver dispatch is not supported. | | Native Q4 isotropic mechanics + AT2 damage | Beta | Structured Q4 mesh helpers, native Q4 mesh input, 2x2-Gauss isotropic mechanics, SciPy/MUMPS sparse-direct stiffness assembly, and matrix-free Q4 AT2 damage are tested. Q4 PF-CZM, AT1, and plasticity are not supported. | diff --git a/docs/user_guide/learned_damage.md b/docs/user_guide/learned_damage.md deleted file mode 100644 index e21955fdf..000000000 --- a/docs/user_guide/learned_damage.md +++ /dev/null @@ -1,214 +0,0 @@ -# Learned damage predictor interface - -## Status and claim boundary - -| Route | Public status | Accepted state | -|---|---|---| -| `classical` | Supported default | Classical phase-field damage solve | -| `learned_proposal` | Experimental interface | Classical solve initialized by a projected prediction | -| `learned_replacement` | Experimental interface | Prediction only after admissibility and projected-residual audits | -| Bundled trained model | Not provided | Users supply and validate their own checkpoint | - -The interface supports research integration. It does not establish the -accuracy, generalization, or speed of any neural architecture. - -## Standard computational route - -PhAST retains one standard finite-element workflow. Geometry definition, mesh -construction, material assignment, initial conditions, loading, boundary -conditions, mechanical equilibrium or time integration, history-field -evaluation, and result generation are independent of the selected damage -update. Only the implementation used to obtain the next phase-field damage -state is selectable. - -```{mermaid} -flowchart LR - A["Geometry, mesh, material,
loads and boundary conditions"] - B["Mechanical FEM update"] - C["Evaluate strain energy
and history field H"] - D{"Damage-update route"} - E["Classical phase-field
damage solve"] - F["Learned damage proposal"] - G["Projection and exact
damage correction"] - H["Admissibility and
projected-residual audit"] - I["Accepted damage state"] - J["Classical fallback"] - K["Advance time or
load increment"] - - A --> B - B --> C - C --> D - D -->|"classical"| E - D -->|"learned_proposal"| F - F --> G - G --> E - D -->|"learned_replacement"| F - F --> H - H -->|"accepted"| I - H -->|"rejected"| J - J --> E - E --> I - I --> K - K --> B - - classDef input fill:#f5f5f4,stroke:#57534e,color:#1c1917,stroke-width:1.2px; - classDef fem fill:#e7e5e4,stroke:#44403c,color:#1c1917,stroke-width:1.2px; - classDef learned fill:#e2e8f0,stroke:#475569,color:#0f172a,stroke-width:1.2px; - classDef audit fill:#f1f5f9,stroke:#64748b,color:#0f172a,stroke-width:1.2px; - classDef decision fill:#fafaf9,stroke:#57534e,color:#1c1917,stroke-width:1.4px; - - class A input; - class B,C,E,I,J,K fem; - class F learned; - class G,H audit; - class D decision; -``` - -### Meaning of replacement - -The optional learned component replaces only the classical solution of the -damage subproblem for an individual update. It does not replace: - -- the finite-element mesh or interpolation; -- the mechanical displacement solve; -- constitutive evaluation; -- the tensile/compressive energy decomposition; -- history-field construction; -- material degradation in the mechanical equations; -- loading, boundary conditions, or time integration; -- output, provenance, or route reporting. - -The phrase *learned replacement* therefore means replacement of the damage -subproblem solve by a predicted nodal damage state. The classical damage solver -remains available as the default route and as the fallback authority. - -### Recommended standard route - -For ordinary simulations, use `damage_update: classical`. For initial research -with a trained model, use `damage_update: learned_proposal`. In this mode the -prediction is projected onto the admissible set and used only as an initial -guess; the classical phase-field solver still determines the accepted damage -state. - -Use `damage_update: learned_replacement` only for an explicitly identified -experimental study. A prediction is accepted only after the configured -physical and residual audits. Failure of any audit invokes the classical -damage solve when fallback is enabled. - -## Configuration - -```yaml -solver: - damage_update: learned_proposal - damage_predictor: my_package.damage_adapter:create_predictor - damage_checkpoint: checkpoints/model.pt - damage_predictor_options: - representation: damage_increment - damage_residual_rtol: 1.0e-3 - damage_residual_atol: 1.0e-8 - damage_bound_tolerance: 1.0e-8 - damage_fallback: true -``` - -The factory is loaded from `module:factory` and called as: - -```python -factory(checkpoint=checkpoint, device=device, options=options) -``` - -It returns an object with: - -```python -predictor.name -predictor.predict(context) -``` - -`predict(context)` returns either a nodal tensor, interpreted as the next -damage field, or: - -```python -DamagePrediction( - damage=prediction, - representation="damage_increment", - diagnostics={"checkpoint": "..."}, -) -``` - -## State available to a plug-in - -`DamageStepContext` exposes: - -- step, time, and load factor; -- node coordinates and element connectivity; -- displacement and velocity; -- element and nodal history fields; -- previous accepted damage; -- material and formulation metadata; -- execution device and dtype. - -`canonical_node_features()` provides a small teaching representation. -`graph_edge_index()` provides dependency-free directed mesh edges. Neither is -mandatory. Architecture adapters may construct graph edge features, -node-element incidence operators, temporal windows, DeepONet branch/trunk -inputs, regular-grid projections, reduced coordinates, or their own normalized -features. - -This separation is deliberate. PhAST owns finite-element state and acceptance; -the plug-in owns architecture-specific preprocessing, checkpoint -interpretation, and inference. - -## Plug-and-play boundary - -The solver-facing interface is plug-and-play: installing a predictor through a -factory and selecting it in YAML does not require modification of the PhAST -mechanics or damage-solver source code. A conforming adapter receives -`DamageStepContext` and returns `DamagePrediction`. - -An arbitrary checkpoint is not automatically portable. Neural architectures -can differ in feature ordering, normalization, temporal history, graph -construction, finite-element incidence operators, grid projection, output -representation, and checkpoint serialization. These differences must be -declared and implemented by the model adapter. - -The reference TorchScript factory demonstrates the contract but is not a -registry of validated models. A future YAML-level model manifest may standardize -known adapters, normalization records, output representations, and checkpoint -metadata. Until such a registry is implemented and tested, the accurate -description is **plug-in compatible**, rather than universally -checkpoint-compatible. - -## Acceptance and fallback - -For `learned_proposal`, PhAST: - -1. checks shape and finite values; -2. projects damage to `[0, 1]`; -3. enforces irreversibility and phase-field Dirichlet values; -4. supplies the result as the classical solver initial guess. - -For `learned_replacement`, PhAST additionally checks: - -1. unprojected bound and irreversibility violations; -2. phase-field Dirichlet consistency; -3. the projected phase-field residual relative to the previous state; -4. an absolute residual floor. - -Rejected replacements use the classical damage solver when -`damage_fallback: true`. Route reports include calls, proposals, accepted -replacements, failures, and fallbacks. - -## Portability - -The predictor receives the device selected by PhAST, but its factory remains -responsible for verifying that the checkpoint and operators support that -device. CPU execution is the portable baseline. CUDA, MPS, and optional sparse -backends depend on the installed PyTorch build and platform-specific packages. - -Automatic route selection is deterministic according to availability; it is -not a guarantee of the fastest route for every mesh or machine. The public CI -workflow is configured to check the core CPU installation and configuration -path on Linux, macOS, and Windows. Optional accelerator and sparse-direct paths -require their own environment-specific evidence. - -See [Modular fracture problems and learned damage updates](../tutorial/03_modular_fem_and_learned_damage.md) -for the student workflow and `examples/learned_damage/` for a minimal factory. diff --git a/examples/learned_damage/README.md b/examples/learned_damage/README.md deleted file mode 100644 index 63f9a464b..000000000 --- a/examples/learned_damage/README.md +++ /dev/null @@ -1,146 +0,0 @@ -# Learned damage plug-in example - -PhAST exposes a small predictor protocol for research on learned damage -updates. The finite-element solver remains responsible for mechanics, -history-field construction, constraints, and acceptance. - -The example provides two adapters: - -- `PersistencePredictor` returns the previously converged damage field. It is - only an interface demonstration and is not a trained surrogate. -- `TorchScriptDamagePredictor` loads a user-supplied TorchScript checkpoint. - The model receives canonical nodal features and element connectivity. - -Add the following fields to a runnable PhAST YAML configuration: - -```yaml -solver: - damage_update: learned_proposal - damage_predictor: examples.learned_damage.predictor_plugin:create_predictor - damage_checkpoint: path/to/model.pt - damage_predictor_options: - representation: damage_increment -``` - -`learned_proposal` uses the prediction only as an initial guess for the -classical damage solve. The converged finite-element update remains -authoritative. - -`learned_replacement` is an experimental option. Before a prediction can -replace the classical update, PhAST checks its shape, finite values, physical -bounds, irreversibility, prescribed phase-field values, and projected damage -residual. A rejected prediction falls back to the classical solve by default. - -The canonical nodal feature columns are: - -```text -x, y, history field, previous damage, displacement x, displacement y -``` - -The canonical features are a teaching convenience, not a mandatory model -schema. A plug-in receives the complete `DamageStepContext` and can call -`context.graph_edge_index()` for mesh-graph models, use coordinates as a -DeepONet trunk, retain a temporal state buffer, project fields to a regular -grid, or construct node-element incidence operators. Architecture-specific -normalization and checkpoint schemas remain inside the plug-in. - -Use `representation: damage` when the model predicts the next field, or -`representation: damage_increment` when it predicts an increment relative to -the previous accepted damage. Both pass through the same PhAST projection and -audit. - -This interface does not prescribe a neural architecture or training -procedure. A predictor intended for scientific use must be trained and -evaluated on data consistent with the mesh representation, material model, -loading protocol, nondimensionalization, and fracture formulation used at -inference time. - -Python callers can install a predictor directly: - -```python -from examples.learned_damage import create_predictor - -predictor = create_predictor(checkpoint="path/to/model.pt", device=solver.device) -solver.set_damage_predictor(predictor, mode="learned_proposal") -``` - -Start with `learned_proposal`. Treat direct replacement as a separate -experimental study requiring independent finite-element comparisons and -complete reporting of all fallback events. - -## Swapping architectures - -The finite-element workflow does not change when a learned predictor is used. -Geometry construction, meshing, named region identification, boundary -conditions, loading, material properties, solver selection and time integration -are written exactly as for a classical run. One key inside the solver block -selects which operation performs the damage update, and a second names the -adapter: - -```yaml -solver: - solver_type: explicit - damage_update: learned_proposal - damage_predictor: examples.learned_damage.architectures.mesh_graph_net:create_predictor - damage_checkpoint: checkpoints/mesh_graph_net.pt - damage_fallback: true -``` - -Changing architecture means changing those two lines: - -| Architecture | `damage_predictor` | -|---|---| -| Persistence, interface demonstration | `examples.learned_damage.predictor_plugin:create_predictor` | -| TorchScript, architecture-agnostic | `examples.learned_damage.predictor_plugin:create_predictor` | -| Mesh-graph network | `examples.learned_damage.architectures.mesh_graph_net:create_predictor` | -| Your own | `my_package.my_architecture:create_predictor` | - -Each architecture needs a wrapper, because each one has its own graph -construction, feature ordering and normalization. The wrapper is the only place -that knowledge belongs. `examples/learned_damage/architectures/template.py` is a -commented skeleton with the two methods to fill in. - -## Finite-element helpers available to any predictor - -`DamageStepContext` supplies the quantities that predictors of every -architecture tend to need, so that a wrapper carries model-specific code only: - -| Method | Returns | -|---|---| -| `canonical_node_features()` | `[x, y, H, d_prev, u_x, u_y]` per node | -| `graph_edge_index()` | directed, duplicate-free mesh edges | -| `element_areas()` | area of every triangle | -| `assemble_element_to_nodes(v)` | `sum_e int N_i v_e dOmega` | -| `boundary_node_mask()` | topological boundary nodes, interior voids included | -| `edge_lengths(edge_index)` | length of every edge | - -## Portable checkpoints - -`architectures/mesh_graph_net.py` provides `export_torchscript`, which converts a -state-dict checkpoint into a TorchScript archive. The archive carries its own -architecture, so it evaluates with no model source on the import path and can be -distributed as a single file. Optimizer state is dropped in the process. - -```python -from examples.learned_damage.architectures.mesh_graph_net import export_torchscript - -export_torchscript("training_checkpoint.pt", "mesh_graph_net.pt") -``` - -Load checkpoints only from sources you trust. The state-dict loader accepts -tensor-and-metadata archives through PyTorch's restricted `weights_only` mode; -the example does not deserialize arbitrary Python objects. - -The `examples.learned_damage...` module paths are available when running from -the PhAST source checkout. They are examples rather than modules installed in -the core `phast` wheel. A deployed predictor should therefore live in its own -installed package or use an application-specific import path. - -## Auditing an accepted damage field - -`phast.solvers.damage_solver.damage_kkt_metrics` reports the box-constrained -KKT conditions for one damage field: projected stationarity, feasibility of the -irreversibility interval, dual sign conditions on the active sets, and -complementarity. Post-clamping and relaxed iterates can hide exactly these -violations, so the projected norms are the right quantity to report when -comparing a learned update against a classical one. diff --git a/examples/learned_damage/__init__.py b/examples/learned_damage/__init__.py deleted file mode 100644 index 0fee99b33..000000000 --- a/examples/learned_damage/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Reference learned-damage plug-ins for PhAST.""" - -from .predictor_plugin import ( - PersistencePredictor, - TorchScriptDamagePredictor, - create_predictor, -) - -__all__ = [ - "PersistencePredictor", - "TorchScriptDamagePredictor", - "create_predictor", -] diff --git a/examples/learned_damage/architectures/__init__.py b/examples/learned_damage/architectures/__init__.py deleted file mode 100644 index 3fa627eb7..000000000 --- a/examples/learned_damage/architectures/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Per-architecture learned-damage adapters. - -Each module here wraps one neural architecture so that it satisfies the PhAST -damage-predictor protocol. The solver, the finite-element workflow and the YAML -schema are identical for all of them; only the ``damage_predictor`` entry in the -solver block changes. Use :mod:`template` as the starting point for a new -architecture. -""" diff --git a/examples/learned_damage/architectures/mesh_graph_net.py b/examples/learned_damage/architectures/mesh_graph_net.py deleted file mode 100644 index 38d4eee84..000000000 --- a/examples/learned_damage/architectures/mesh_graph_net.py +++ /dev/null @@ -1,424 +0,0 @@ -"""Mesh-graph network adapter for the learned damage subproblem. - -One architecture, wrapped so that PhAST can call it. The finite-element -workflow is untouched: geometry, mesh, named regions, boundary conditions, -loading, material and time integration all remain exactly as they are for a -classical run. The only change is the damage subproblem's ``damage_predictor`` -entry. - -The generic finite-element quantities this adapter needs are supplied by -:class:`phast.learned_damage.DamageStepContext`. What is specific to this -architecture, and therefore lives here, is the feature contract: - -``node_features[:, 0]`` - ``sum_e int N_i H_e / (Gc * l0) dOmega``, the assembled and normalized - history integral. The network applies ``log10(1 + x)`` to this column - internally, so the raw assembled value is passed. -``node_features[:, 1]`` - A boundary indicator. See :func:`bounding_box_indicator` for the variant - used when the reference checkpoints were trained. -``edge_features[:, 0]`` - Edge length divided by ``l0``. - -A checkpoint evaluated under a different normalization, assembly or edge -feature still produces a plausible crack pattern while no longer representing -the trained operator. Reproducing the training-time construction exactly is the -adapter's responsibility, not the solver's. - -Usage ------ -.. code-block:: yaml - - solver: - damage_update: learned_proposal - damage_predictor: examples.learned_damage.architectures.mesh_graph_net:create_predictor - damage_checkpoint: checkpoints/mesh_graph_net.pt - damage_predictor_options: - representation: damage - boundary_indicator: bounding_box - damage_fallback: true -""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any, Mapping - -import torch -from torch import nn - -from phast.learned_damage import DamagePrediction, DamageStepContext - -__all__ = [ - "MeshGraphDamageNet", - "MeshGraphNetPredictor", - "bounding_box_indicator", - "create_predictor", - "export_torchscript", - "load_network", -] - - -# ---------------------------------------------------------------- network - -def _mlp(in_features: int, out_features: int, hidden: int, layers: int) -> nn.Sequential: - modules: list[nn.Module] = [] - width = in_features - for _ in range(layers): - modules.append(nn.Linear(width, hidden)) - modules.append(nn.ReLU()) - width = hidden - modules.append(nn.Linear(width, out_features)) - return nn.Sequential(*modules) - - -class SymmetricProcessorBlock(nn.Module): - """One permutation-symmetric message-passing block.""" - - def __init__(self, latent_dim: int, hidden_layers: int) -> None: - super().__init__() - triple = 3 * latent_dim - self.edge_update = _mlp(triple, latent_dim, latent_dim, hidden_layers) - self.message_norm = nn.LayerNorm(triple) - self.message = _mlp(triple, latent_dim, latent_dim, hidden_layers) - self.node_norm = nn.LayerNorm(2 * latent_dim) - self.node_update = _mlp(2 * latent_dim, latent_dim, latent_dim, hidden_layers) - - def forward( - self, - node_latent: torch.Tensor, - edge_latent: torch.Tensor, - edge_index: torch.Tensor, - ) -> tuple[torch.Tensor, torch.Tensor]: - # Indexed rather than unpacked so that the block stays scriptable. - src, dst = edge_index[0], edge_index[1] - symmetric = torch.cat( - [ - node_latent[src] + node_latent[dst], - torch.abs(node_latent[src] - node_latent[dst]), - edge_latent, - ], - dim=-1, - ) - edge_latent = edge_latent + self.edge_update(symmetric) - symmetric = torch.cat( - [ - node_latent[src] + node_latent[dst], - torch.abs(node_latent[src] - node_latent[dst]), - edge_latent, - ], - dim=-1, - ) - messages = self.message(self.message_norm(symmetric)) - aggregate = torch.zeros_like(node_latent) - aggregate.index_add_(0, dst, messages) - degree = torch.zeros( - (node_latent.shape[0], 1), - dtype=node_latent.dtype, - device=node_latent.device, - ) - degree.index_add_( - 0, - dst, - torch.ones((dst.numel(), 1), dtype=node_latent.dtype, device=dst.device), - ) - aggregate = aggregate / degree.clamp_min(1.0) - node_input = self.node_norm(torch.cat([node_latent, aggregate], dim=-1)) - node_latent = node_latent + self.node_update(node_input) - return node_latent, edge_latent - - -class MeshGraphDamageNet(nn.Module): - """Encoder-processor-decoder mesh-graph surrogate for the damage field. - - The decoder is linear. Box constraints and irreversibility are applied by - the PhAST runtime coupling rather than by an output activation here. - """ - - def __init__( - self, - *, - latent_dim: int = 32, - hidden_layers: int = 3, - message_passing_steps: int = 10, - ) -> None: - super().__init__() - self.node_encoder = nn.Sequential( - _mlp(2, latent_dim, latent_dim, hidden_layers), - nn.LayerNorm(latent_dim), - ) - self.edge_encoder = nn.Sequential( - _mlp(1, latent_dim, latent_dim, hidden_layers), - nn.LayerNorm(latent_dim), - ) - self.processor = nn.ModuleList( - SymmetricProcessorBlock(latent_dim, hidden_layers) - for _ in range(message_passing_steps) - ) - self.decoder = _mlp(latent_dim, 1, latent_dim, hidden_layers) - - def forward( - self, - node_features: torch.Tensor, - edge_index: torch.Tensor, - edge_features: torch.Tensor, - ) -> torch.Tensor: - transformed = node_features.clone() - transformed[:, 0] = torch.log10(1.0 + transformed[:, 0].clamp_min(0.0)) - node_latent = self.node_encoder(transformed) - edge_latent = self.edge_encoder(edge_features) - for block in self.processor: - node_latent, edge_latent = block(node_latent, edge_latent, edge_index) - return self.decoder(node_latent).squeeze(-1) - - -# ---------------------------------------------------------------- checkpoints - -_ARCHITECTURE_KEYS = ("latent_dim", "hidden_layers", "message_passing_steps") - - -def load_network( - checkpoint: str | Path, - device: torch.device | str = "cpu", -) -> tuple[nn.Module, dict[str, Any]]: - """Load a TorchScript archive or a state-dict checkpoint. - - A TorchScript archive carries its own architecture, so evaluating it needs - no model source. A state-dict checkpoint must supply the three architecture - entries in a ``configuration`` mapping. - """ - path = Path(checkpoint) - try: - model = torch.jit.load(str(path), map_location=device) - model.eval() - return model, {"format": "torchscript", "path": str(path.resolve())} - except (RuntimeError, ValueError): - pass - - payload = torch.load(str(path), map_location="cpu", weights_only=True) - if not isinstance(payload, Mapping) or "model_state_dict" not in payload: - raise ValueError( - f"{path} is neither a TorchScript archive nor a checkpoint holding " - "'model_state_dict'") - configuration = dict(payload.get("configuration", {})) - missing = [key for key in _ARCHITECTURE_KEYS if key not in configuration] - if missing: - raise ValueError( - f"{path} is missing architecture entries {missing} in its " - "'configuration' mapping") - model = MeshGraphDamageNet( - latent_dim=int(configuration["latent_dim"]), - hidden_layers=int(configuration["hidden_layers"]), - message_passing_steps=int(configuration["message_passing_steps"]), - ) - model.load_state_dict(payload["model_state_dict"], strict=True) - model.to(device).eval() - metadata = { - "format": "state_dict", - "path": str(path.resolve()), - **{key: configuration[key] for key in _ARCHITECTURE_KEYS}, - } - for key in ("variant", "Gc", "length_scale", "seed"): - if key in configuration: - metadata[key] = configuration[key] - if "completed_epochs" in payload: - metadata["completed_epochs"] = int(payload["completed_epochs"]) - return model, metadata - - -def export_torchscript( - checkpoint: str | Path, - output: str | Path, - *, - n_nodes: int = 64, - verify: bool = True, -) -> Path: - """Convert a state-dict checkpoint into a portable TorchScript archive. - - The archive evaluates without this module on the import path, so trained - weights can be distributed without model source. Optimizer state is - dropped, which makes the archive substantially smaller than a training - checkpoint. - """ - model, metadata = load_network(checkpoint, device="cpu") - if metadata["format"] == "torchscript": - raise ValueError(f"{checkpoint} is already a TorchScript archive") - - scripted = torch.jit.script(model) - if verify: - generator = torch.Generator().manual_seed(0) - node_features = torch.rand(n_nodes, 2, generator=generator) - n_edges = 4 * n_nodes - edge_index = torch.randint(0, n_nodes, (2, n_edges), generator=generator) - edge_features = torch.rand(n_edges, 1, generator=generator) - with torch.no_grad(): - reference = model(node_features, edge_index, edge_features) - candidate = scripted(node_features, edge_index, edge_features) - assert torch.equal(reference, candidate), ( - "scripted model disagrees with the eager model") - - output = Path(output) - output.parent.mkdir(parents=True, exist_ok=True) - torch.jit.save(scripted, str(output)) - return output - - -# ---------------------------------------------------------------- features - -def bounding_box_indicator( - nodes: torch.Tensor, *, atol: float = 1.0e-10 -) -> torch.Tensor: - """Flag nodes on the bounding box of the mesh. - - This marks the outer extent only, so interior voids such as holes and slots - carry no flag. It is retained because the reference checkpoints were trained - against it. For the topological boundary, which does detect interior voids, - use :meth:`phast.learned_damage.DamageStepContext.boundary_node_mask`, - selected here by ``boundary_indicator: topological``. - """ - x, y = nodes[:, 0], nodes[:, 1] - on_edge = ( - torch.isclose(x, x.min(), atol=atol) - | torch.isclose(x, x.max(), atol=atol, rtol=0.0) - | torch.isclose(y, y.min(), atol=atol, rtol=0.0) - | torch.isclose(y, y.max(), atol=atol, rtol=0.0) - ) - return on_edge.to(dtype=nodes.dtype) - - -# ---------------------------------------------------------------- adapter - -class MeshGraphNetPredictor: - """Wrap :class:`MeshGraphDamageNet` for the PhAST damage-predictor protocol. - - Parameters - ---------- - model - A network taking ``(node_features, edge_index, edge_features)``. - representation - ``'damage'`` when the network predicts the next field, or - ``'damage_increment'`` when it predicts an increment relative to the - previous accepted damage. - boundary_indicator - ``'bounding_box'`` to reproduce the training-time construction, or - ``'topological'`` to mark interior voids as well. - length_scale, Gc - Override the values taken from the running material. Supply these only - when the checkpoint was trained under a different normalization, and - record the choice in any reported result. - metadata - Provenance recovered from the checkpoint, reported in diagnostics. - """ - - name = "mesh-graph-net" - - def __init__( - self, - model: nn.Module, - *, - representation: str = "damage", - boundary_indicator: str = "bounding_box", - length_scale: float | None = None, - Gc: float | None = None, - metadata: Mapping[str, Any] | None = None, - ) -> None: - if boundary_indicator not in {"bounding_box", "topological"}: - raise ValueError( - "boundary_indicator must be 'bounding_box' or 'topological'; " - f"got {boundary_indicator!r}") - self.model = model.eval() - self.representation = str(representation) - self.boundary_indicator = boundary_indicator - self.length_scale = length_scale - self.Gc = Gc - self.metadata = dict(metadata or {}) - self._graph_cache: tuple[ - tuple[int, int, int, int, float], torch.Tensor, torch.Tensor - ] | None = None - - @staticmethod - def _resolve(name: str, override: float | None, context: DamageStepContext) -> float: - if override is not None: - return float(override) - value = context.material.get(name) - if value is None: - raise ValueError( - f"the running material exposes no {name!r}; pass it explicitly " - "to MeshGraphNetPredictor") - return float(value) - - def _graph(self, context: DamageStepContext) -> tuple[torch.Tensor, torch.Tensor]: - """Edges and edge features, rebuilt only when the mesh changes.""" - length_scale = self._resolve("l0", self.length_scale, context) - key = ( - int(context.nodes.data_ptr()), - int(context.elements.data_ptr()), - int(context.nodes.shape[0]), - int(context.elements.shape[0]), - float(length_scale), - ) - if self._graph_cache is not None and self._graph_cache[0] == key: - return self._graph_cache[1], self._graph_cache[2] - edge_index = context.graph_edge_index() - edge_features = ( - context.edge_lengths(edge_index) / length_scale).reshape(-1, 1) - self._graph_cache = (key, edge_index, edge_features) - return edge_index, edge_features - - def node_features(self, context: DamageStepContext) -> torch.Tensor: - """Build the two nodal features this network was trained on.""" - Gc = self._resolve("Gc", self.Gc, context) - length_scale = self._resolve("l0", self.length_scale, context) - assembled = context.assemble_element_to_nodes( - context.history_element.reshape(-1) / (Gc * length_scale)) - if self.boundary_indicator == "bounding_box": - indicator = bounding_box_indicator(context.nodes) - else: - indicator = context.boundary_node_mask().to(dtype=assembled.dtype) - return torch.stack((assembled, indicator.to(assembled.dtype)), dim=1) - - def predict(self, context: DamageStepContext) -> DamagePrediction: - edge_index, edge_features = self._graph(context) - features = self.node_features(context) - model_dtype = next( - (parameter.dtype for parameter in self.model.parameters()), - torch.float32, - ) - with torch.no_grad(): - raw = self.model( - features.to(device=context.device, dtype=model_dtype), - edge_index.to(device=context.device), - edge_features.to(device=context.device, dtype=model_dtype), - ) - damage = torch.as_tensor( - raw, device=context.device, dtype=context.dtype, - ).reshape_as(context.damage_previous) - return DamagePrediction( - damage=damage, - representation=self.representation, - diagnostics={ - "adapter": self.name, - "n_edges": int(edge_index.shape[1]), - "boundary_indicator": self.boundary_indicator, - **self.metadata, - }, - ) - - -def create_predictor( - *, - checkpoint: str | Path, - device: torch.device | str = "cpu", - options: Mapping[str, Any] | None = None, -) -> MeshGraphNetPredictor: - """Factory matching the ``module:factory`` entry in a YAML configuration.""" - options = dict(options or {}) - model, metadata = load_network(checkpoint, device=device) - return MeshGraphNetPredictor( - model, - representation=options.get("representation", "damage"), - boundary_indicator=options.get("boundary_indicator", "bounding_box"), - length_scale=options.get("length_scale"), - Gc=options.get("Gc"), - metadata=metadata, - ) diff --git a/examples/learned_damage/architectures/template.py b/examples/learned_damage/architectures/template.py deleted file mode 100644 index f8e1b093f..000000000 --- a/examples/learned_damage/architectures/template.py +++ /dev/null @@ -1,141 +0,0 @@ -"""Template for a new learned-damage architecture. - -Copy this file, rename it after the architecture, and fill in the two marked -places. Nothing outside this file changes: the geometry, mesh, named regions, -boundary conditions, loading, material, solver and time integration are the same -as for a classical run, and the simulation selects the architecture through one -YAML entry. - -.. code-block:: yaml - - solver: - damage_update: learned_proposal - damage_predictor: my_package.my_architecture:create_predictor - damage_checkpoint: checkpoints/my_model.pt - damage_predictor_options: - representation: damage - damage_fallback: true - -What PhAST guarantees ---------------------- -A predictor is called once per damage subproblem and receives a -:class:`phast.learned_damage.DamageStepContext` describing the current -finite-element state. PhAST owns the admissibility audit, the projected -residual check, the box and irreversibility constraints, route reporting and -the classical fallback. A predictor proposes; it never decides. - -What the context offers ------------------------ -Raw state - ``nodes``, ``elements``, ``displacement``, ``velocity``, - ``history_element``, ``history_nodal``, ``damage_previous``, ``material``, - ``step``, ``time``, ``load_factor``, ``device``, ``dtype``. - -Finite-element helpers, architecture-neutral - ``canonical_node_features()`` returns ``[x, y, H, d_prev, u_x, u_y]``. - ``graph_edge_index()`` returns directed, duplicate-free mesh edges. - ``element_areas()`` returns the area of every triangle. - ``assemble_element_to_nodes(values)`` returns - ``sum_e int N_i value_e dOmega``. - ``boundary_node_mask()`` flags topological boundary nodes, interior voids - included. - ``edge_lengths(edge_index)`` returns the length of every edge. - -Anything beyond these, such as a radius graph, a regular-grid projection, a -trunk basis for an operator network, a temporal state buffer, or a -normalization recovered from a training manifest, is built here. That is the -whole purpose of a per-architecture wrapper: the solver stays neutral and each -model brings its own preprocessing. - -Obligations ------------ -1. Return one value per mesh node, shaped like ``context.damage_previous``. -2. Return it on ``context.device`` in ``context.dtype``. -3. Declare ``representation``: ``'damage'`` for the next field, or - ``'damage_increment'`` for an increment on the previous accepted damage. -4. Reproduce the training-time feature construction exactly. A mismatch in - normalization or feature order still yields a plausible crack pattern while - no longer representing the trained operator. -""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any, Mapping - -import torch - -from phast.learned_damage import DamagePrediction, DamageStepContext - -__all__ = ["TemplatePredictor", "create_predictor"] - - -class TemplatePredictor: - """Minimal predictor skeleton. Replace the two marked bodies.""" - - #: Reported in the route summary and in prediction diagnostics. - name = "template" - - def __init__( - self, - model: Any, - *, - representation: str = "damage", - metadata: Mapping[str, Any] | None = None, - ) -> None: - self.model = model - self.representation = str(representation) - self.metadata = dict(metadata or {}) - - def build_inputs(self, context: DamageStepContext) -> tuple[Any, ...]: - """Build whatever this architecture consumes. - - Replace this body. The example below is the simplest useful case: the - canonical nodal features and the mesh graph. An operator network might - return coordinates and a history field instead; a radius-graph model - would build its own neighbourhood here. - """ - features = context.canonical_node_features() - edge_index = context.graph_edge_index() - return features, edge_index - - def predict(self, context: DamageStepContext) -> DamagePrediction: - """Call the model and return one nodal damage field. - - Replace the model call. Keep the surrounding contract: no gradient, one - value per node, the context's device and dtype. - """ - inputs = self.build_inputs(context) - with torch.no_grad(): - raw = self.model(*inputs) # replace with this architecture's call - - damage = torch.as_tensor( - raw, device=context.device, dtype=context.dtype, - ).reshape_as(context.damage_previous) - return DamagePrediction( - damage=damage, - representation=self.representation, - diagnostics={"adapter": self.name, **self.metadata}, - ) - - -def create_predictor( - *, - checkpoint: str | Path, - device: torch.device | str = "cpu", - options: Mapping[str, Any] | None = None, -) -> TemplatePredictor: - """Factory matching the ``module:factory`` entry in a YAML configuration. - - PhAST calls this with the checkpoint path, the resolved device and the - ``damage_predictor_options`` mapping. Loading, architecture reconstruction - and checkpoint validation belong here. - """ - options = dict(options or {}) - model = torch.jit.load(str(checkpoint), map_location=device) - model.eval() - return TemplatePredictor( - model, - representation=options.get("representation", "damage"), - metadata={"path": str(Path(checkpoint).resolve())}, - ) diff --git a/examples/learned_damage/predictor_plugin.py b/examples/learned_damage/predictor_plugin.py deleted file mode 100644 index b0508a0bf..000000000 --- a/examples/learned_damage/predictor_plugin.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Minimal learned-damage plug-in contract. - -The persistence predictor is an interface demonstration, not a useful damage -model. The TorchScript adapter shows one portable way to load a trained model -without coupling PhAST to a particular neural-network architecture. -""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any, Mapping, Optional - -import torch - -from phast.learned_damage import DamagePrediction, DamageStepContext - - -class PersistencePredictor: - """Return the previous converged damage field.""" - - name = "persistence-demonstration" - - def predict(self, context: DamageStepContext) -> DamagePrediction: - return DamagePrediction( - damage=context.damage_previous.clone(), - diagnostics={"purpose": "interface demonstration"}, - ) - - -class TorchScriptDamagePredictor: - """Adapt a TorchScript model to the PhAST damage-predictor protocol.""" - - def __init__( - self, - checkpoint: str, - device: torch.device, - representation: str = "damage", - ) -> None: - self.name = f"torchscript:{Path(checkpoint).name}" - self.representation = representation - self.model = torch.jit.load(checkpoint, map_location=device) - self.model.eval() - - def predict(self, context: DamageStepContext) -> DamagePrediction: - with torch.no_grad(): - damage = self.model( - context.canonical_node_features(), - context.elements, - ) - return DamagePrediction( - damage=torch.as_tensor( - damage, - device=context.device, - dtype=context.dtype, - ).reshape_as(context.damage_previous), - representation=self.representation, - diagnostics={"adapter": "torchscript"}, - ) - - -def create_predictor( - checkpoint: Optional[str] = None, - device: Optional[torch.device] = None, - options: Optional[Mapping[str, Any]] = None, -): - """Factory used by ``solver.damage_predictor`` configuration.""" - - options = dict(options or {}) - if checkpoint: - return TorchScriptDamagePredictor( - checkpoint=checkpoint, - device=device or torch.device("cpu"), - representation=str(options.get("representation", "damage")), - ) - return PersistencePredictor() diff --git a/examples/standard_workflow/dynamic_sent/config.yaml b/examples/standard_workflow/dynamic_sent/config.yaml index 1c2f62401..4693df442 100644 --- a/examples/standard_workflow/dynamic_sent/config.yaml +++ b/examples/standard_workflow/dynamic_sent/config.yaml @@ -55,7 +55,7 @@ analysis_steps: controls: {protocol: simple, num_steps: 4, dt: 1.0e-8, ramp_type: smooth_step, t_ramp: 2.0e-8} # 8. Select existing Velocity-Verlet mechanics and the classical AT2 damage solve. -# No quasi-static Newton backend, learned model, or three-dimensional route is used. +# No quasi-static Newton backend or three-dimensional route is used. solver: type: explicit device: cpu diff --git a/examples/standard_workflow/quasistatic_sent/config.yaml b/examples/standard_workflow/quasistatic_sent/config.yaml index aba582444..15464c6e8 100644 --- a/examples/standard_workflow/quasistatic_sent/config.yaml +++ b/examples/standard_workflow/quasistatic_sent/config.yaml @@ -54,7 +54,7 @@ analysis_steps: controls: {protocol: simple, num_steps: 4, dt: 0.25} # 8. Select existing staggered equilibrium/damage solves with SciPy mechanics. -# Tolerances control the existing solvers; no dynamics or learned model is used. +# Tolerances control the existing solvers; no dynamics is used. solver: type: quasi_static device: cpu diff --git a/src/phast/__init__.py b/src/phast/__init__.py index 0e20eaf02..fc7c1156e 100644 --- a/src/phast/__init__.py +++ b/src/phast/__init__.py @@ -67,11 +67,6 @@ DirectSolver, LBFGSSolver, MonolithicSolver, ) from .staggered_solver import StaggeredSolver, SolverConfig - from .learned_damage import ( - DamageDecision, DamagePrediction, DamagePredictionRejected, - DamagePredictor, DamageStepContext, DamageUpdateController, - load_damage_predictor, - ) from .device import DeviceContext, detect_device, get_device_tier, estimate_vram_mb, Profiler from .problem import Problem from .result import Result, ResultLoadError, load_result @@ -132,9 +127,6 @@ 'ExplicitDynamics', 'StaticSolver', 'QuasiStaticSolver', 'SecantCGSolver', 'LBFGSSolver', 'MonolithicSolver', 'StaggeredSolver', 'SolverConfig', - 'DamageDecision', 'DamagePrediction', 'DamagePredictionRejected', - 'DamagePredictor', 'DamageStepContext', 'DamageUpdateController', - 'load_damage_predictor', # Device / performance 'DeviceContext', 'detect_device', 'get_device_tier', 'estimate_vram_mb', 'Profiler', # Convenience diff --git a/src/phast/config/config.py b/src/phast/config/config.py index 2ae10210d..dc70387f6 100644 --- a/src/phast/config/config.py +++ b/src/phast/config/config.py @@ -379,16 +379,6 @@ class SolverSettings: softmax_H_beta: Optional[float] = None H_update_method: str = 'hard_max' enable_damage: bool = True - # Damage-subproblem route. ``classical`` is the default. Learned routes - # require a user-supplied ``module:factory`` predictor. - damage_update: str = 'classical' - damage_predictor: Optional[str] = None - damage_checkpoint: Optional[str] = None - damage_predictor_options: dict = field(default_factory=dict) - damage_residual_rtol: float = 1.0e-3 - damage_residual_atol: float = 1.0e-8 - damage_bound_tolerance: float = 1.0e-8 - damage_fallback: bool = True fail_on_mechanics_nonconvergence: bool = True fail_on_stagger_nonconvergence: bool = True adaptive_dt: bool = False @@ -1085,14 +1075,6 @@ def resolve_config(config: ProblemConfig) -> dict: softmax_H_beta=s.softmax_H_beta, H_update_method=s.H_update_method, enable_damage=s.enable_damage, - damage_update=s.damage_update, - damage_predictor=s.damage_predictor, - damage_checkpoint=s.damage_checkpoint, - damage_predictor_options=s.damage_predictor_options, - damage_residual_rtol=s.damage_residual_rtol, - damage_residual_atol=s.damage_residual_atol, - damage_bound_tolerance=s.damage_bound_tolerance, - damage_fallback=s.damage_fallback, fail_on_mechanics_nonconvergence=s.fail_on_mechanics_nonconvergence, fail_on_stagger_nonconvergence=s.fail_on_stagger_nonconvergence, adaptive_dt=s.adaptive_dt, diff --git a/src/phast/config/config_validation.py b/src/phast/config/config_validation.py index fed02b8f6..35eaa1b5c 100755 --- a/src/phast/config/config_validation.py +++ b/src/phast/config/config_validation.py @@ -78,9 +78,6 @@ # several anisotropic-PF papers since (issue #244). 'solver.stagger_norm': ['l2', 'linf'], 'solver.bounds_method': ['post_clamp', 'projected_cg'], - 'solver.damage_update': [ - 'classical', 'learned_proposal', 'learned_replacement', - ], # Issue #360 — H-update operator dispatcher. ``hard_max`` (default) # is byte-identical to ``torch.maximum``; the other methods are # opt-in differentiable alternatives. @@ -139,9 +136,6 @@ 'solver.H_cap_factor': (0.0, None), 'solver.stagger_tol': (0.0, None), 'solver.damage_tol': (0.0, None), - 'solver.damage_residual_rtol': (0.0, None), - 'solver.damage_residual_atol': (0.0, None), - 'solver.damage_bound_tolerance': (0.0, None), 'solver.static_tol': (0.0, None), 'material.sigma_ts': (0.0, None), 'material.pfczm_p': (2, None), @@ -800,8 +794,6 @@ def _validate_cross_field_compatibility(raw: dict, line_map: dict, solver_type = solver.get('solver_type', 'explicit') time_integrator = solver.get('time_integrator') - damage_update = solver.get('damage_update', 'classical') - damage_predictor = solver.get('damage_predictor') pf_model = str(_raw_material_value(raw, 'pf_model') or '').upper() degradation_type = str( _raw_material_value(raw, 'degradation_type') or 'standard' @@ -852,32 +844,6 @@ def _validate_cross_field_compatibility(raw: dict, line_map: dict, "damage-update route." ), )) - if damage_update != 'classical' and not damage_predictor: - errors.append(ValidationError( - path='solver.damage_predictor', - message=( - f"damage_update={damage_update!r} requires a predictor factory" - ), - line_no=line_map.get('solver.damage_update', - line_map.get('solver', 0)), - suggestion=( - "Set solver.damage_predictor to 'module:factory', or use " - "solver.damage_update: classical." - ), - )) - if damage_update == 'classical' and damage_predictor: - errors.append(ValidationError( - path='solver.damage_predictor', - message=( - "damage_predictor is configured but damage_update is classical" - ), - line_no=line_map.get('solver.damage_predictor', - line_map.get('solver', 0)), - suggestion=( - "Remove solver.damage_predictor or select " - "learned_proposal/learned_replacement explicitly." - ), - )) if solver_type != 'explicit' and time_integrator is not None: errors.append(ValidationError( path='solver.time_integrator', diff --git a/src/phast/core/fem_operators.py b/src/phast/core/fem_operators.py index aed655143..e0ebafdf5 100644 --- a/src/phast/core/fem_operators.py +++ b/src/phast/core/fem_operators.py @@ -1651,98 +1651,6 @@ def compute_crack_length(self, d, threshold=0.95): span = pts.max(0).values - pts.min(0).values return torch.norm(span).item() - def compute_physics_loss(self, u: torch.Tensor, d: torch.Tensor, - H_elem: torch.Tensor, bc_mask=None, - f_ext=None) -> torch.Tensor: - """Differentiable AT2 physics loss for neural operator training. - - Returns ||R_d||^2 + ||R_u||^2 where: - - R_d = AT2 damage residual (should be zero at equilibrium) - - R_u = mechanics residual f_int(u,d) - f_ext (should be zero at equilibrium) - - All operations preserve autograd graphs so gradients flow back - through u and d into the neural operator. - - Parameters - ---------- - u : (N, 2) displacement (from neural operator or solver) - d : (N,) damage (from neural operator or solver) - H_elem : (E,) driving force history variable - bc_mask : (N, 2) bool or None - If provided, constrained DOFs are excluded from the mechanics - residual (their equilibrium is enforced by BCs, not by R_u=0). - f_ext : (N, 2) or None - External force vector. If provided, the mechanics residual is - R_u = f_int - f_ext instead of R_u = f_int. - - Returns - ------- - loss : scalar tensor (differentiable) - """ - mesh = self.mesh - mat = self.material - pf_model = getattr(mat, 'pf_model', 'AT2').upper() - if pf_model != 'AT2': - raise NotImplementedError( - "FEMOperators.compute_physics_loss() currently implements " - f"the AT2 damage residual only; got pf_model={pf_model!r}. " - "Use AT2 or add a model-specific residual before using this " - "loss for AT1/PF-CZM training." - ) - Gc = mat.Gc - l0 = mat.l0 - - # --- Mechanics residual: f_int - f_ext should be zero at equilibrium --- - f_int = self.internal_force(u, d) - if f_ext is not None: - R_u = f_int - f_ext - else: - R_u = f_int - if bc_mask is not None: - R_u = R_u.clone() - R_u[bc_mask] = 0.0 # exclude constrained DOFs - R_u_sq = (R_u ** 2).sum() - - # --- Damage residual: R_d = A*d - b --- - # AT2 weak form: Gc*l0*K_lap*d + (2H + Gc/l0)*M*d - 2H*M*1 = 0 - # where M is the consistent mass matrix: area/12 * [2,1,1;1,2,1;1,1,2]. - # This matches PhaseFieldDamageSolver._Ax() exactly. - - # Laplacian stiffness term: Gc*l0 * K_lap @ d - K_lap_d = self.laplacian_matvec(d) - stiff_term = Gc * l0 * K_lap_d - - # Reaction (mass) term: (2H + Gc/l0) * M * d - # Consistent mass: M_e @ d_e = (area/12) * (d_a + d_sum) for node a - # where d_sum = sum of all 3 nodal d values in the element. - areas = mesh.areas - elements = mesh.elements - elem_flat = self._elem_flat - - rc = (2.0 * H_elem + Gc / l0) * areas / 12.0 # (E,) - d_e = d[elements] # (E, 3) - d_sum = d_e.sum(1) # (E,) - # Per-node contribution: rc * (d_a + d_sum) - react_contrib = rc.unsqueeze(1) * (d_e + d_sum.unsqueeze(1)) # (E, 3) - react_term = torch.zeros(mesh.n_nodes, dtype=self.dtype, - device=self.device) - react_term.scatter_add_(0, elem_flat, react_contrib.flatten()) - - # RHS: b = 2H * M * 1 (consistent mass applied to ones vector) - # M*1_a = area/12 * (1 + 3) = area/3 per node (since 1_sum = 3) - # But with nodal H: b_a = area/6 * (H_a + H_sum) — see damage_solver - # For element-level H, all 3 nodes get same H_e, so: - # b_a = 2*H_e * area/12 * (1 + 3) = 2*H_e * area/3 - rhs_coeff = 2.0 * H_elem * areas / 3.0 # (E,) - rhs_contrib = rhs_coeff.unsqueeze(1).expand(-1, 3).flatten() - b = torch.zeros(mesh.n_nodes, dtype=self.dtype, device=self.device) - b.scatter_add_(0, elem_flat, rhs_contrib) - - R_d = stiff_term + react_term - b - R_d_sq = (R_d ** 2).sum() - - return R_u_sq + R_d_sq - def compute_crack_front_indicator(self, d: torch.Tensor, d_prev: torch.Tensor, dt: float) -> torch.Tensor: """Compute crack front indicator as d * (dd/dt). diff --git a/src/phast/learned_damage.py b/src/phast/learned_damage.py deleted file mode 100644 index c1fff7947..000000000 --- a/src/phast/learned_damage.py +++ /dev/null @@ -1,550 +0,0 @@ -"""Public contracts for learned damage updates in staggered FEM simulations. - -The classical phase-field damage solver remains the default and authoritative -route. A learned model may be used in one of two explicitly selected modes: - -``learned_proposal`` - The prediction is projected to the admissible interval and supplied as the - initial guess to the classical damage solve. - -``learned_replacement`` - The prediction replaces one damage subproblem only when it passes finite - value, shape, bound, irreversibility, phase-field Dirichlet, and projected - residual checks. A rejected prediction falls back to the classical solve - unless fallback is disabled. - -This module defines inference contracts. It does not define a network -architecture, training procedure, dataset, or generalization claim. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -import importlib -import math -from typing import Any, Callable, Mapping, Protocol, runtime_checkable - -import torch - - -LEARNED_DAMAGE_MODES = { - "classical", - "learned_proposal", - "learned_replacement", -} - - -@dataclass(frozen=True) -class DamageStepContext: - """Read-only description of one damage-subproblem call. - - Tensor fields are detached views or clones supplied for inference. A model - must return one nodal damage value per mesh node. - """ - - step: int - time: float | None - load_factor: float - nodes: torch.Tensor - elements: torch.Tensor - displacement: torch.Tensor - velocity: torch.Tensor - history_element: torch.Tensor - history_nodal: torch.Tensor - damage_previous: torch.Tensor - material: Mapping[str, Any] - phase_field_model: str - energy_split: str - device: torch.device - dtype: torch.dtype - - def canonical_node_features(self) -> torch.Tensor: - """Return ``[x, y, H, d_prev, u_x, u_y]`` node features. - - The helper is intentionally small and architecture-neutral. Models that - require graph edges, quadrature history, normalization, or additional - material channels should construct them from the full context. - """ - - coordinates = self.nodes[:, :2].to( - device=self.device, dtype=self.dtype) - return torch.cat( - ( - coordinates, - self.history_nodal.reshape(-1, 1), - self.damage_previous.reshape(-1, 1), - self.displacement[:, :2], - ), - dim=1, - ) - - def graph_edge_index(self) -> torch.Tensor: - """Return directed, duplicate-free mesh edges as ``[2, n_edges]``. - - Plug-ins may ignore this helper, construct node-element incidence - operators, or add edge features. PhAST does not require PyTorch - Geometric for the public predictor contract. - """ - - local_nodes = self.elements.shape[1] - pairs = [] - for local_index in range(local_nodes): - left = self.elements[:, local_index] - right = self.elements[:, (local_index + 1) % local_nodes] - pairs.append(torch.stack((left, right), dim=1)) - pairs.append(torch.stack((right, left), dim=1)) - edges = torch.cat(pairs, dim=0) - return torch.unique(edges, dim=0).t().contiguous() - - def element_areas(self) -> torch.Tensor: - """Return the area of every three-node triangle in the mesh.""" - - if self.elements.shape[1] != 3: - raise NotImplementedError( - "element_areas currently covers three-node triangles; got " - f"{self.elements.shape[1]} local nodes") - xy = self.nodes[:, :2][self.elements] - x0, x1, x2 = xy[:, 0], xy[:, 1], xy[:, 2] - twice_area = ( - (x1[:, 0] - x0[:, 0]) * (x2[:, 1] - x0[:, 1]) - - (x2[:, 0] - x0[:, 0]) * (x1[:, 1] - x0[:, 1]) - ) - return 0.5 * twice_area.abs() - - def assemble_element_to_nodes(self, values: torch.Tensor) -> torch.Tensor: - """Assemble ``sum_e int N_i value_e dOmega`` over the mesh. - - This is the finite-element integral of a linear shape function against - a piecewise-constant element field, without mass inversion. Divide by - the lumped mass returned for a unit field to obtain a nodal average - instead. - """ - - values = values.reshape(-1) - if values.shape[0] != self.elements.shape[0]: - raise ValueError( - "values must hold one entry per element; got " - f"{values.shape[0]} for {self.elements.shape[0]} elements") - n_local = self.elements.shape[1] - weighted = ( - values * self.element_areas() / n_local - ).repeat_interleave(n_local) - accumulated = torch.zeros( - self.nodes.shape[0], dtype=weighted.dtype, device=weighted.device) - accumulated.index_add_(0, self.elements.reshape(-1), weighted) - return accumulated - - def boundary_node_mask(self) -> torch.Tensor: - """Flag nodes on the topological boundary of the mesh. - - An element edge shared by exactly one element lies on a boundary. The - test is topological, so interior voids such as holes and slots are - detected alongside the outer perimeter. - """ - - n_local = self.elements.shape[1] - pairs = torch.cat( - [ - torch.stack( - ( - self.elements[:, local], - self.elements[:, (local + 1) % n_local], - ), - dim=1, - ) - for local in range(n_local) - ], - dim=0, - ) - undirected, counts = torch.unique( - pairs.sort(dim=1).values, dim=0, return_counts=True) - boundary_edges = undirected[counts == 1] - mask = torch.zeros( - self.nodes.shape[0], dtype=torch.bool, device=self.nodes.device) - mask[boundary_edges.reshape(-1)] = True - return mask - - def edge_lengths(self, edge_index: torch.Tensor) -> torch.Tensor: - """Return the Euclidean length of every edge in ``edge_index``.""" - - delta = ( - self.nodes[:, :2][edge_index[0]] - self.nodes[:, :2][edge_index[1]] - ) - return torch.linalg.vector_norm(delta, dim=1) - - -@dataclass(frozen=True) -class DamagePrediction: - """Prediction returned by a learned damage model.""" - - damage: torch.Tensor - diagnostics: Mapping[str, Any] = field(default_factory=dict) - representation: str = "damage" - - -@runtime_checkable -class DamagePredictor(Protocol): - """Protocol implemented by a learned damage predictor.""" - - name: str - - def predict( - self, - context: DamageStepContext, - ) -> DamagePrediction | torch.Tensor: - """Return a nodal damage field or damage increment.""" - - -class DamagePredictionRejected(RuntimeError): - """Raised when replacement is rejected and fallback is disabled.""" - - -@dataclass(frozen=True) -class DamageDecision: - """Audited decision for one learned damage prediction.""" - - route: str - candidate: torch.Tensor | None - accepted_replacement: bool - reason: str - residual_relative: float | None = None - residual_rms: float | None = None - lower_bound_violation: float | None = None - upper_bound_violation: float | None = None - diagnostics: Mapping[str, Any] = field(default_factory=dict) - - -def _predictor_name(predictor: Any) -> str: - return str( - getattr( - predictor, - "name", - getattr(predictor, "__name__", predictor.__class__.__name__), - ) - ) - - -def load_damage_predictor( - specification: str, - *, - checkpoint: str | None, - device: torch.device, - options: Mapping[str, Any] | None = None, -) -> DamagePredictor | Callable[[DamageStepContext], Any]: - """Load a predictor from a public ``module:factory`` specification. - - The factory is called as:: - - factory(checkpoint=..., device=..., options={...}) - - It must return an object with ``predict(context)`` or a callable accepting - one :class:`DamageStepContext`. Checkpoint interpretation belongs to the - user-provided factory so PhAST does not impose one neural architecture. - """ - - if ":" not in specification: - raise ValueError( - "damage_predictor must use 'module:factory' notation; " - f"received {specification!r}" - ) - module_name, factory_name = specification.split(":", 1) - module = importlib.import_module(module_name) - factory = getattr(module, factory_name, None) - if factory is None or not callable(factory): - raise ValueError( - f"Damage predictor factory {factory_name!r} was not found as a " - f"callable in module {module_name!r}." - ) - predictor = factory( - checkpoint=checkpoint, - device=device, - options=dict(options or {}), - ) - if isinstance(predictor, torch.nn.Module): - predictor.eval() - if not hasattr(predictor, "predict") and not callable(predictor): - raise TypeError( - "A damage predictor factory must return an object with " - "predict(context) or a callable accepting DamageStepContext." - ) - return predictor - - -class DamageUpdateController: - """Audit learned predictions and select the damage-update route.""" - - def __init__( - self, - predictor: DamagePredictor | Callable[[DamageStepContext], Any], - *, - mode: str = "learned_proposal", - residual_rtol: float = 1.0e-3, - residual_atol: float = 1.0e-8, - bound_tolerance: float = 1.0e-8, - fallback: bool = True, - ) -> None: - if mode not in LEARNED_DAMAGE_MODES - {"classical"}: - raise ValueError( - "Learned damage mode must be 'learned_proposal' or " - f"'learned_replacement'; received {mode!r}." - ) - if residual_rtol < 0 or residual_atol < 0 or bound_tolerance < 0: - raise ValueError("Damage audit tolerances must be non-negative.") - self.predictor = predictor - self.mode = mode - self.residual_rtol = float(residual_rtol) - self.residual_atol = float(residual_atol) - self.bound_tolerance = float(bound_tolerance) - self.fallback = bool(fallback) - self.calls = 0 - self.proposals = 0 - self.accepted_replacements = 0 - self.fallbacks = 0 - self.failures = 0 - self.last_decision: DamageDecision | None = None - - @property - def predictor_name(self) -> str: - return _predictor_name(self.predictor) - - def _predict(self, context: DamageStepContext) -> DamagePrediction: - with torch.no_grad(): - if hasattr(self.predictor, "predict"): - output = self.predictor.predict(context) - else: - output = self.predictor(context) - if isinstance(output, DamagePrediction): - return output - if isinstance(output, torch.Tensor): - return DamagePrediction(output) - raise TypeError( - "Damage predictor output must be a torch.Tensor or " - "DamagePrediction." - ) - - @staticmethod - def _projected_residual( - residual: torch.Tensor, - damage: torch.Tensor, - damage_previous: torch.Tensor, - tolerance: float, - ) -> torch.Tensor: - lower_active = damage <= damage_previous + tolerance - upper_active = damage >= 1.0 - tolerance - projected = torch.where( - lower_active, - torch.minimum(residual, torch.zeros_like(residual)), - residual, - ) - projected = torch.where( - upper_active, - torch.maximum(projected, torch.zeros_like(projected)), - projected, - ) - return projected - - def _reject( - self, - reason: str, - *, - diagnostics: Mapping[str, Any] | None = None, - **metrics: Any, - ) -> DamageDecision: - self.failures += 1 - if self.fallback: - self.fallbacks += 1 - decision = DamageDecision( - route="classical_fallback", - candidate=None, - accepted_replacement=False, - reason=reason, - diagnostics=dict(diagnostics or {}), - **metrics, - ) - self.last_decision = decision - return decision - raise DamagePredictionRejected(reason) - - def decide( - self, - context: DamageStepContext, - *, - damage_solver: Any, - phase_field_mask: torch.Tensor | None = None, - phase_field_values: torch.Tensor | None = None, - ) -> DamageDecision: - """Return an audited proposal, replacement, or fallback decision.""" - - self.calls += 1 - try: - prediction = self._predict(context) - except Exception as exc: - return self._reject(f"predictor error: {exc}") - - diagnostics = dict(prediction.diagnostics) - diagnostics.setdefault("representation", prediction.representation) - raw_prediction = prediction.damage.detach().to( - device=context.device, dtype=context.dtype) - if raw_prediction.shape != context.damage_previous.shape: - return self._reject( - "prediction shape does not match nodal damage shape", - diagnostics=diagnostics, - ) - if prediction.representation == "damage": - candidate = raw_prediction - elif prediction.representation == "damage_increment": - candidate = context.damage_previous + raw_prediction - else: - return self._reject( - "prediction representation must be 'damage' or " - "'damage_increment'", - diagnostics=diagnostics, - ) - if not bool(torch.isfinite(candidate).all()): - return self._reject( - "prediction contains NaN or infinite values", - diagnostics=diagnostics, - ) - - damage_previous = context.damage_previous - lower_violation = float( - torch.clamp(damage_previous - candidate, min=0).max().item()) - upper_violation = float( - torch.clamp(candidate - 1.0, min=0).max().item()) - - if self.mode == "learned_proposal": - candidate = torch.maximum( - torch.clamp(candidate, min=0.0, max=1.0), - damage_previous, - ) - if phase_field_mask is not None and phase_field_values is not None: - candidate = torch.where( - phase_field_mask.to(device=context.device), - phase_field_values.to( - device=context.device, dtype=context.dtype), - candidate, - ) - self.proposals += 1 - decision = DamageDecision( - route="learned_proposal_exact_correction", - candidate=candidate, - accepted_replacement=False, - reason="admissible prediction supplied as exact-solver initial guess", - lower_bound_violation=lower_violation, - upper_bound_violation=upper_violation, - diagnostics=diagnostics, - ) - self.last_decision = decision - return decision - - if ( - lower_violation > self.bound_tolerance - or upper_violation > self.bound_tolerance - ): - return self._reject( - "prediction violates damage bounds or irreversibility", - diagnostics=diagnostics, - lower_bound_violation=lower_violation, - upper_bound_violation=upper_violation, - ) - - if phase_field_mask is not None and phase_field_values is not None: - mask = phase_field_mask.to(device=context.device) - values = phase_field_values.to( - device=context.device, dtype=context.dtype) - if bool(mask.any()): - bc_error = float( - torch.abs(candidate[mask] - values[mask]).max().item()) - if bc_error > self.bound_tolerance: - return self._reject( - "prediction violates a phase-field Dirichlet condition", - diagnostics=diagnostics, - lower_bound_violation=lower_violation, - upper_bound_violation=upper_violation, - ) - - try: - residual = damage_solver.compute_residual( - context.history_element, candidate) - reference = damage_solver.compute_residual( - context.history_element, damage_previous) - except Exception as exc: - return self._reject( - f"damage residual audit failed: {exc}", - diagnostics=diagnostics, - lower_bound_violation=lower_violation, - upper_bound_violation=upper_violation, - ) - - projected = self._projected_residual( - residual, - candidate, - damage_previous, - self.bound_tolerance, - ) - if phase_field_mask is not None: - projected = torch.where( - phase_field_mask.to(device=projected.device), - torch.zeros_like(projected), - projected, - ) - projected_norm = float(torch.linalg.vector_norm(projected).item()) - reference_norm = float(torch.linalg.vector_norm(reference).item()) - residual_relative = projected_norm / max( - reference_norm, - torch.finfo(projected.dtype).eps, - ) - residual_rms = projected_norm / math.sqrt(max(projected.numel(), 1)) - residual_ok = ( - residual_relative <= self.residual_rtol - or residual_rms <= self.residual_atol - ) - if not residual_ok: - return self._reject( - "prediction failed the projected damage-residual audit", - diagnostics=diagnostics, - residual_relative=residual_relative, - residual_rms=residual_rms, - lower_bound_violation=lower_violation, - upper_bound_violation=upper_violation, - ) - - self.accepted_replacements += 1 - decision = DamageDecision( - route="learned_replacement", - candidate=candidate.clone(), - accepted_replacement=True, - reason="prediction passed admissibility and residual audits", - residual_relative=residual_relative, - residual_rms=residual_rms, - lower_bound_violation=lower_violation, - upper_bound_violation=upper_violation, - diagnostics=diagnostics, - ) - self.last_decision = decision - return decision - - def summary(self) -> dict[str, Any]: - """Return machine-readable controller statistics.""" - - return { - "mode": self.mode, - "predictor": self.predictor_name, - "calls": self.calls, - "proposals": self.proposals, - "accepted_replacements": self.accepted_replacements, - "fallbacks": self.fallbacks, - "failures": self.failures, - "residual_rtol": self.residual_rtol, - "residual_atol": self.residual_atol, - "bound_tolerance": self.bound_tolerance, - "fallback_enabled": self.fallback, - "last_route": ( - None if self.last_decision is None - else self.last_decision.route - ), - "last_reason": ( - None if self.last_decision is None - else self.last_decision.reason - ), - } diff --git a/src/phast/solvers/damage_solver.py b/src/phast/solvers/damage_solver.py index 40e5ea723..31bdd30ec 100644 --- a/src/phast/solvers/damage_solver.py +++ b/src/phast/solvers/damage_solver.py @@ -2352,8 +2352,8 @@ def _solve_forward_with_material_overrides( pf_dirichlet_mask=None, pf_dirichlet_values=None): """Forward-only solve with temporary material overrides. - This path is used by no-grad dataset generation and live hybrid - audits. It temporarily installs the per-element material arrays, runs + This path is used by no-grad dataset generation and forward-only + residual checks. It temporarily installs the per-element material arrays, runs the normal constrained solver, and restores the cached scalar or gamma-corrected arrays afterwards. """ diff --git a/src/phast/solvers/staggered_solver.py b/src/phast/solvers/staggered_solver.py index d983d9d3a..a50be3262 100644 --- a/src/phast/solvers/staggered_solver.py +++ b/src/phast/solvers/staggered_solver.py @@ -16,7 +16,7 @@ - H capping for out-of-distribution stability (Pantidis 2026) - Modular sub-solvers (swappable mechanics + damage) - Support for quasi-static NR (not just explicit dynamics) - - Hooks for neural operator injection (set_damage, get_H_nodal) + - Hooks for external state access (set_damage, get_H_nodal) """ import torch @@ -39,11 +39,6 @@ init_h5, CSVHistory) from ..utils.device import DeviceContext from ..plasticity import DuctilePhaseFieldCoupling, MeshJ2Elastoplasticity -from ..learned_damage import ( - DamageStepContext, - DamageUpdateController, - load_damage_predictor, -) @dataclass @@ -112,14 +107,6 @@ class SolverConfig: # ExplicitDynamics docstring. Zero = pure Verlet (default). damping_ratio_max: float = 0.0 enable_damage: bool = True # False = pure elastic (skip damage solve + H update) - damage_update: str = 'classical' # classical | learned_proposal | learned_replacement - damage_predictor: Optional[str] = None # module:factory - damage_checkpoint: Optional[str] = None - damage_predictor_options: dict | None = None - damage_residual_rtol: float = 1.0e-3 - damage_residual_atol: float = 1.0e-8 - damage_bound_tolerance: float = 1.0e-8 - damage_fallback: bool = True # Linear-solver backend for the QuasiStaticSolver Newton inner step. # Forwarded to QuasiStaticSolver(backend=...) in _build_mechanics_solver. # 'auto' picks the best available sparse-direct path; 'scipy', 'mumps', @@ -168,18 +155,6 @@ class SolverConfig: print_every: int = 10 def __post_init__(self): - if self.damage_update not in { - 'classical', 'learned_proposal', 'learned_replacement', - }: - raise ValueError( - "damage_update must be classical, learned_proposal, or " - f"learned_replacement; got {self.damage_update!r}" - ) - if self.damage_update != 'classical' and not self.damage_predictor: - raise ValueError( - f"damage_update={self.damage_update!r} requires " - "damage_predictor='module:factory'." - ) if (self.preconditioner is None and self.solver_type in ('static', 'quasi_static', 'quasi_static_legacy', 'lbfgs', @@ -405,23 +380,6 @@ def __init__(self, mesh: FEMMesh, material: Material, self._last_mechanics_converged = True self._last_mechanics_iter = 0 self._last_dt_used = 0.0 # for adaptive-dt history (forward compat) - self.damage_controller = None - self._last_damage_route = 'classical' - if config.damage_update != 'classical': - predictor = load_damage_predictor( - config.damage_predictor, - checkpoint=config.damage_checkpoint, - device=self.device, - options=config.damage_predictor_options, - ) - self.set_damage_predictor( - predictor, - mode=config.damage_update, - residual_rtol=config.damage_residual_rtol, - residual_atol=config.damage_residual_atol, - bound_tolerance=config.damage_bound_tolerance, - fallback=config.damage_fallback, - ) print(f"[StaggeredSolver] State allocated: {N} nodes, {E} elements, " f"device={self.device}, dtype={self.dtype}", flush=True) self.print_route_report() @@ -925,15 +883,15 @@ def step_compute_driving_force(self, strain=None) -> torch.Tensor: self.H_elem = torch.clamp(self.H_elem, max=self.config.H_cap) # Note: elem_to_node performs area-weighted averaging, which smooths - # peak H values near the crack tip. Neural operators using get_H_nodal() - # will see a smoother driving force than the element-level H_elem that - # the damage solver actually uses. For NN training, consider using - # H_elem directly with element-to-node projection at inference time. + # peak H values near the crack tip. Callers of get_H_nodal() therefore + # see a smoother driving force than the element-level H_elem that the + # damage solver actually uses; read H_elem directly when the + # element-level values are required. self.H_nodal = self.mesh.elem_to_node(self.H_elem) return psi def step_solve_damage(self, d_prev_step=None): - """Update damage through the configured classical or learned route. + """Solve AT2 for damage. If `self.diff_Gc` and/or `self.diff_l0` are set (0-d tensors, typically with requires_grad=True), they are forwarded to the @@ -974,28 +932,10 @@ def step_solve_damage(self, d_prev_step=None): pf_mask, pf_vals = self.bcs.get_pf_dirichlet_mask_values() d_prev_arg = d_prev_step if d_prev_step is not None else self.d initial_guess = self.d if d_prev_step is not None else None - decision = None - if self.damage_controller is not None: - context = self._damage_step_context(d_prev_arg) - decision = self.damage_controller.decide( - context, - damage_solver=self.damage_solver, - phase_field_mask=pf_mask, - phase_field_values=pf_vals, - ) - self._last_damage_route = decision.route - if decision.accepted_replacement: - self.d = decision.candidate - self._apply_pf_dirichlet() - return - if decision.candidate is not None: - initial_guess = decision.candidate self.d = self.damage_solver.solve( self.H_elem, d_prev_arg, Gc=Gc, l0=l0, Gc_field=Gc_field, pf_dirichlet_mask=pf_mask, pf_dirichlet_values=pf_vals, initial_guess=initial_guess) - if decision is None: - self._last_damage_route = 'classical' # Phase-field Dirichlet enforcement (issue #213): re-pin the # damage value at every step on nodes flagged with the # pf_dirichlet BC (matches COMSOL pre-crack convention). @@ -1404,7 +1344,7 @@ def step_full(self) -> torch.Tensor: return psi # ------------------------------------------------------------------ # - # Neural operator integration hooks + # External state and material-field hooks # ------------------------------------------------------------------ # def install_diff_E_field(self, E_field: torch.Tensor): @@ -1467,86 +1407,21 @@ def install_diff_E_field(self, E_field: torch.Tensor): self._E_field_first_install = True def set_damage(self, d_new: torch.Tensor): - """Inject external damage field (e.g., from GINO).""" + """Replace the current damage field with an externally supplied one.""" self.d = d_new.clone() def get_H_nodal(self) -> torch.Tensor: - """Get current history variable at nodes (for neural operator input).""" + """Get the current history variable projected to the nodes.""" return self.H_nodal - def set_damage_predictor( - self, predictor, *, mode='learned_proposal', - residual_rtol=1.0e-3, residual_atol=1.0e-8, - bound_tolerance=1.0e-8, fallback=True): - """Install a learned damage predictor for subsequent damage updates. - - ``learned_proposal`` always retains the classical damage solve. - ``learned_replacement`` accepts a prediction only after the audits - implemented by :class:`phast.DamageUpdateController`. - """ - self.damage_controller = DamageUpdateController( - predictor, - mode=mode, - residual_rtol=residual_rtol, - residual_atol=residual_atol, - bound_tolerance=bound_tolerance, - fallback=fallback, - ) - self.config.damage_update = mode - self._last_damage_route = mode - return self - - def clear_damage_predictor(self): - """Restore the classical damage-subproblem route.""" - self.damage_controller = None - self.config.damage_update = 'classical' - self._last_damage_route = 'classical' - return self - - def _damage_step_context(self, damage_previous): - material_fields = { - name: getattr(self.material, name, None) - for name in ( - 'E', 'nu', 'Gc', 'l0', 'rho', 'eta_residual', - 'plane_stress', 'kinematics', - ) - } - time_value = ( - None if self.dt is None - else float(self._step_count * self.dt) - ) - return DamageStepContext( - step=int(self._step_count), - time=time_value, - load_factor=float(getattr(self.bcs, 'load_factor', 1.0)), - nodes=self.mesh.nodes.detach(), - elements=self.mesh.elements.detach(), - displacement=self.u.detach(), - velocity=self.v.detach(), - history_element=self.H_elem.detach(), - history_nodal=self.H_nodal.detach(), - damage_previous=damage_previous.detach(), - material=material_fields, - phase_field_model=str( - getattr(self.material, 'pf_model', 'AT2')), - energy_split=str( - getattr(self.material, 'energy_split', 'unknown')), - device=self.device, - dtype=self.dtype, - ) - def route_report(self): """Return the selected mechanics, damage, device, and physics route.""" backend_resolved = getattr(self.mechanics, 'last_backend', None) damage = { 'model': str(getattr(self.material, 'pf_model', 'AT2')), - 'update': self.config.damage_update, 'bounds': self.config.bounds_method, 'preconditioner': self.config.preconditioner, - 'last_route': self._last_damage_route, } - if self.damage_controller is not None: - damage.update(self.damage_controller.summary()) return { 'device': str(self.device), 'dtype': str(self.dtype), @@ -1587,30 +1462,19 @@ def print_route_report(self): f" fracture={report['phase_field_model']}; " f"energy_split={report['energy_split']}; " f"history_update={report['history_update']}; " - f"damage_update={damage['update']}; " f"bounds={damage['bounds']}; " f"preconditioner={damage['preconditioner']}", flush=True, ) - if self.damage_controller is not None: - print( - f" predictor={damage['predictor']}; " - f"residual_rtol={damage['residual_rtol']:.3e}; " - f"residual_atol={damage['residual_atol']:.3e}; " - f"bound_tolerance={damage['bound_tolerance']:.3e}; " - f"fallback={damage['fallback_enabled']}", - flush=True, - ) return report def get_state(self) -> dict: """Get full solver state as a dict (for checkpointing/rollback). - Hybrid solver-DL rollouts use this state for path-dependent rollback. - Keep it broader than the primary variables: rejected learned proposals - must also restore history diagnostics and installed heterogeneous - material fields so the trusted correction does not inherit stale - internal state. + The state supports path-dependent rollback. Keep it broader than the + primary variables: a restored step must also recover the history + diagnostics and installed heterogeneous material fields so that the + repeated solve does not inherit stale internal state. """ state = { 'u': self.u.clone(), @@ -2121,71 +1985,3 @@ def run(self, num_steps: int = None, output_dir: str = None, print("Done.") self.steps = int(num_steps) return history - - -class AIBridge: - """Compatibility adapter for one externally supplied damage prediction. - - New integrations should use :meth:`StaggeredSolver.set_damage_predictor`. - This adapter now applies the same admissibility and projected-residual - audit as the public learned-replacement route. - - Parameters - ---------- - solver : StaggeredSolver - residual_threshold : float - Maximum relative projected residual. Default 1e-3. - """ - - def __init__(self, solver, residual_threshold=1e-3): - self.solver = solver - self.threshold = residual_threshold - self.nn_steps = 0 - self.fallback_steps = 0 - - def step_with_nn(self, d_nn): - """Accept neural operator damage prediction, validate, maybe correct. - - Parameters - ---------- - d_nn : (N,) damage prediction from neural operator - - Returns - ------- - d_final : (N,) validated damage field - used_solver : bool — True if FEM solver was needed - """ - controller = DamageUpdateController( - lambda _context: d_nn, - mode='learned_replacement', - residual_rtol=self.threshold, - fallback=True, - ) - pf_mask = pf_vals = None - if getattr(self.solver.bcs, 'pf_dirichlet_bcs', None): - pf_mask, pf_vals = ( - self.solver.bcs.get_pf_dirichlet_mask_values()) - decision = controller.decide( - self.solver._damage_step_context(self.solver.d), - damage_solver=self.solver.damage_solver, - phase_field_mask=pf_mask, - phase_field_values=pf_vals, - ) - if decision.accepted_replacement: - self.solver.d = decision.candidate - self.solver._apply_pf_dirichlet() - self.nn_steps += 1 - return self.solver.d, False - self.solver.d = self.solver.damage_solver.solve( - self.solver.H_elem, self.solver.d) - self.fallback_steps += 1 - return self.solver.d, True - - def stats(self): - total = self.nn_steps + self.fallback_steps - if total == 0: - return "AIBridge: no steps taken" - pct = self.nn_steps / total * 100 - return (f"AIBridge: {total} steps, " - f"{self.nn_steps} NN ({pct:.0f}%), " - f"{self.fallback_steps} fallback ({100-pct:.0f}%)") diff --git a/src/phast/training/__init__.py b/src/phast/training/__init__.py deleted file mode 100644 index 0a61e512a..000000000 --- a/src/phast/training/__init__.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Training utilities (standalone scaffolds, not yet wired into a trainer).""" - -from phast.training.losses import ( - adaptive_loss_weights, - dice_loss, - dice_plus_focal, - focal_loss, - log_l2_loss, - log_relative_error_loss, - rprop_step_factor, - weighted_loss_sum, -) -from phast.training.self_training import ( - SelfTrainingConfig, - filter_by_quality, - inverse_age_weights, - merge_with_labelled, - pseudo_label_step, -) - -__all__ = [ - "adaptive_loss_weights", - "dice_loss", - "dice_plus_focal", - "focal_loss", - "log_l2_loss", - "log_relative_error_loss", - "rprop_step_factor", - "weighted_loss_sum", - "SelfTrainingConfig", - "filter_by_quality", - "inverse_age_weights", - "merge_with_labelled", - "pseudo_label_step", -] diff --git a/src/phast/training/losses.py b/src/phast/training/losses.py deleted file mode 100644 index 7d3b0b873..000000000 --- a/src/phast/training/losses.py +++ /dev/null @@ -1,161 +0,0 @@ -"""Advanced loss functions for damage prediction (#51). - -Standalone module — not yet wired into a training pipeline. The training -pipeline that consumes these is separate work. - -Implements literature quick-wins from issue #51: -- log(E) loss (Manav 2024) -> log_l2_loss, log_relative_error_loss -- adaptive loss balancing (Kiyani 25) -> adaptive_loss_weights, weighted_loss_sum -- RPROP step factor (Manav 2024) -> rprop_step_factor -- Dice + Focal (Hamdi 2026) -> dice_loss, focal_loss, dice_plus_focal - -All functions are float64-compatible and autograd-friendly. -""" - -from __future__ import annotations - -import torch - - -def log_l2_loss( - pred: torch.Tensor, target: torch.Tensor, eps: float = 1e-8 -) -> torch.Tensor: - """log(L2) loss — bounds gradient magnitude across orders of magnitude. - - Returns: ``log(eps + mean((pred - target)**2))``. - """ - return torch.log(eps + ((pred - target) ** 2).mean()) - - -def log_relative_error_loss( - pred: torch.Tensor, target: torch.Tensor, eps: float = 1e-8 -) -> torch.Tensor: - """log relative error — invariant to absolute scale of target. - - Returns: ``log(eps + mean(|pred - target|) / (mean(|target|) + eps))``. - """ - num = (pred - target).abs().mean() - den = target.abs().mean() + eps - return torch.log(eps + num / den) - - -def dice_loss( - pred: torch.Tensor, target: torch.Tensor, smooth: float = 1.0 -) -> torch.Tensor: - """Dice loss for damage segmentation: ``1 - 2|P intersect T| / (|P| + |T|)``. - - Both ``pred`` and ``target`` should be in [0, 1]. Treats damage as a soft mask. - """ - p = pred.flatten() - t = target.flatten() - intersection = (p * t).sum() - return 1.0 - (2.0 * intersection + smooth) / (p.sum() + t.sum() + smooth) - - -def focal_loss( - pred: torch.Tensor, - target: torch.Tensor, - alpha: float = 0.25, - gamma: float = 2.0, - eps: float = 1e-8, -) -> torch.Tensor: - """Binary focal loss — down-weights well-classified examples. - - Both inputs assumed to be probabilities in [0, 1]. Useful for crack - localisation where most elements have ``d ~ 0`` (easy negatives). - """ - pred = pred.clamp(eps, 1.0 - eps) - is_positive = target > 0.5 - pt = torch.where(is_positive, pred, 1.0 - pred) - alpha_t = torch.where( - is_positive, - torch.as_tensor(alpha, dtype=pred.dtype, device=pred.device), - torch.as_tensor(1.0 - alpha, dtype=pred.dtype, device=pred.device), - ) - return -(alpha_t * (1.0 - pt) ** gamma * pt.log()).mean() - - -def dice_plus_focal( - pred: torch.Tensor, - target: torch.Tensor, - lambda_dice: float = 1.0, - lambda_focal: float = 1.0, - alpha: float = 0.25, - gamma: float = 2.0, -) -> torch.Tensor: - """Combined Dice + Focal — popular for class-imbalanced segmentation.""" - return lambda_dice * dice_loss(pred, target) + lambda_focal * focal_loss( - pred, target, alpha, gamma - ) - - -def rprop_step_factor( - grad: torch.Tensor, - prev_grad: torch.Tensor, - eta_plus: float = 1.2, - eta_minus: float = 0.5, - step_min: float = 1e-6, - step_max: float = 50.0, -) -> torch.Tensor: - """RPROP-style adaptive step factor. - - For each parameter, multiply its step size by ``eta_plus`` when ``grad`` - sign matches ``prev_grad``, ``eta_minus`` otherwise. Returns the - multiplicative step factor, clamped to ``[step_min, step_max]``. - """ - sign_change = grad * prev_grad - factor = torch.where( - sign_change > 0, - torch.full_like(grad, eta_plus), - torch.where( - sign_change < 0, - torch.full_like(grad, eta_minus), - torch.ones_like(grad), - ), - ) - return factor.clamp(min=step_min, max=step_max) - - -def adaptive_loss_weights( - losses: dict[str, torch.Tensor], eps: float = 1e-8 -) -> dict[str, torch.Tensor]: - """Inverse-magnitude weighting (Kiyani 2025). - - Returns detached tensor weights ``w_i = (1 / (|L_i| + eps)) / sum_j(...)`` - on the same dtype/device as the first loss. The weights do not propagate - gradients, but they remain tensors so callers can use them directly in a - torch training loop without losing dtype/device information. - """ - if not losses: - raise ValueError("losses must contain at least one term") - first = next(iter(losses.values())) - dtype = first.dtype - device = first.device - inv_terms: dict[str, torch.Tensor] = {} - with torch.no_grad(): - for name, loss in losses.items(): - mag = loss.detach().abs().to(dtype=dtype, device=device) - inv_terms[name] = 1.0 / (mag + eps) - denom = torch.stack([v.reshape(()) for v in inv_terms.values()]).sum() - weights = {name: inv / denom for name, inv in inv_terms.items()} - return weights - - -def weighted_loss_sum( - losses: dict[str, torch.Tensor], - weights: dict[str, torch.Tensor] | None = None, - eps: float = 1e-8, -) -> torch.Tensor: - """Return ``sum_i w_i L_i`` using adaptive weights by default.""" - if not losses: - raise ValueError("losses must contain at least one term") - if weights is None: - weights = adaptive_loss_weights(losses, eps=eps) - total = None - for name, loss in losses.items(): - if name not in weights: - raise KeyError(f"missing weight for loss term {name!r}") - term = weights[name].to(dtype=loss.dtype, device=loss.device) * loss - total = term if total is None else total + term - assert total is not None - return total diff --git a/src/phast/training/self_training.py b/src/phast/training/self_training.py deleted file mode 100644 index cc811c373..000000000 --- a/src/phast/training/self_training.py +++ /dev/null @@ -1,177 +0,0 @@ -"""Self-training augmentation for damage rollout prediction (#56). - -Pipeline: after the teacher-forced phase, run the model autoregressively, -filter rollout snapshots by a quality criterion (e.g. PDE residual below -threshold or confidence above threshold), and append them to the training -set with an inverse-age decay weight ``w = 1 / (1 + age)``. - -References ----------- -- Yarowsky (1995) — bootstrapping for word-sense disambiguation -- Lee (2013) — pseudo-label deep learning -- Sohn et al. (2020) — FixMatch (consistency + confidence filtering) -- Schwarzer et al. (2019) — temporal downsampling + self-training (rollout - predictions added back into the training set; the source for #56) - -Standalone scaffold — not wired into a training loop. All ops are -float64-compatible and autograd-friendly where applicable; the filtering -step is wrapped in ``torch.no_grad()`` because pseudo-labels enter the -graph as constants (the gradient flow during retraining comes from the -standard supervised loss, not from the labelling step). -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Callable, Optional, Tuple - -import torch - - -@dataclass -class SelfTrainingConfig: - """Knobs for the self-training augmentation pass.""" - - quality_threshold: float = 0.9 - """For confidence mode: minimum predicted confidence in [0, 1]. - For residual mode: maximum allowed PDE residual (inputs above are dropped). - """ - use_residual_filter: bool = False - """If True, filter by a user-supplied PDE-residual callable (Schwarzer 2019); - else filter by output confidence (Lee 2013). - """ - use_entropy_confidence: bool = True - """When in confidence mode and outputs are class-prob: if True use - ``1 - entropy / log(C)``, else use max-prob. Ignored for scalar outputs. - """ - max_pseudo_per_pass: int = 10000 - """Cap on number of pseudo-labelled samples appended per pass.""" - age_decay: bool = True - """If True, weight pseudo samples by ``1 / (1 + age)`` (Schwarzer 2019).""" - - -def _confidence_from_probs( - probs: torch.Tensor, use_entropy: bool -) -> torch.Tensor: - """(n, C) probabilities -> (n,) confidence scalar in [0, 1].""" - if use_entropy: - eps = torch.tensor(1e-12, dtype=probs.dtype, device=probs.device) - clamped = probs.clamp(min=eps) - entropy = -(clamped * clamped.log()).sum(dim=-1) - n_classes = probs.shape[-1] - log_c = torch.log( - torch.tensor(float(n_classes), dtype=probs.dtype, device=probs.device) - ) - return 1.0 - entropy / log_c - return probs.max(dim=-1).values - - -def filter_by_quality( - scores: torch.Tensor, - config: SelfTrainingConfig, -) -> torch.Tensor: - """Boolean mask (n,) selecting samples that pass the quality filter. - - ``scores`` is *confidence* (higher better, threshold is lower bound) when - ``use_residual_filter=False``, else *residual* (lower better, threshold - is upper bound). - """ - if config.use_residual_filter: - return scores <= config.quality_threshold - return scores >= config.quality_threshold - - -def inverse_age_weights( - ages: torch.Tensor, -) -> torch.Tensor: - """Inverse-age decay weights ``w = 1 / (1 + age)`` (Schwarzer 2019).""" - return 1.0 / (1.0 + ages.to(dtype=torch.float64)) - - -def pseudo_label_step( - model_predict: Callable[[torch.Tensor], torch.Tensor], - unlabelled_inputs: torch.Tensor, - config: SelfTrainingConfig, - residual_fn: Optional[Callable[[torch.Tensor, torch.Tensor], torch.Tensor]] = None, -) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]: - """Run one pseudo-labelling pass. - - Parameters - ---------- - model_predict - Callable returning model output for a batch of inputs. May return - scalar regression output of shape (n, ...) or class probabilities - of shape (n, C). - unlabelled_inputs - (n, ...) batch of unlabelled features (e.g. autoregressive rollout - snapshots that were not seen during teacher-forced training). - config - Filter knobs. - residual_fn - Required when ``config.use_residual_filter=True``. Maps - ``(inputs, predictions) -> (n,)`` per-sample residual scalars. - - Returns - ------- - (filtered_inputs, pseudo_labels, scores) - with ``n_kept <= n``. ``scores`` are the confidence/residual values - used for filtering; useful for downstream weighting. - """ - with torch.no_grad(): - preds = model_predict(unlabelled_inputs) - if config.use_residual_filter: - if residual_fn is None: - raise ValueError("residual_fn required when use_residual_filter=True") - scores = residual_fn(unlabelled_inputs, preds) - else: - if preds.dim() >= 2 and preds.shape[-1] > 1: - scores = _confidence_from_probs(preds, config.use_entropy_confidence) - else: - # Scalar regression: treat distance from {0, 1} as a - # binary-confidence proxy (damage ∈ [0, 1] in PF problems). - p = preds.reshape(preds.shape[0], -1).mean(dim=-1) - scores = (p - 0.5).abs() * 2.0 - mask = filter_by_quality(scores, config) - if int(mask.sum().item()) > config.max_pseudo_per_pass: - kept_idx = torch.nonzero(mask, as_tuple=True)[0] - if config.use_residual_filter: - topk = torch.topk( - -scores[kept_idx], k=config.max_pseudo_per_pass - ).indices - else: - topk = torch.topk( - scores[kept_idx], k=config.max_pseudo_per_pass - ).indices - new_mask = torch.zeros_like(mask) - new_mask[kept_idx[topk]] = True - mask = new_mask - if preds.dim() >= 2 and preds.shape[-1] > 1 and not config.use_residual_filter: - pseudo_labels = preds.argmax(dim=-1) - else: - pseudo_labels = preds - return unlabelled_inputs[mask], pseudo_labels[mask], scores[mask] - - -def merge_with_labelled( - labelled_x: torch.Tensor, - labelled_y: torch.Tensor, - pseudo_x: torch.Tensor, - pseudo_y: torch.Tensor, - pseudo_ages: Optional[torch.Tensor] = None, -) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]: - """Concatenate labelled + pseudo into a single combined dataset. - - Pseudo samples are appended last so their per-sample weights can be - addressed via slicing. Real samples receive weight 1.0; pseudo samples - receive ``1 / (1 + age)`` if ``pseudo_ages`` is provided, else 1.0. - """ - x = torch.cat([labelled_x, pseudo_x], dim=0) - y = torch.cat([labelled_y, pseudo_y], dim=0) - real_w = torch.ones(labelled_x.shape[0], dtype=torch.float64) - pseudo_w = ( - inverse_age_weights(pseudo_ages) - if pseudo_ages is not None - else torch.ones(pseudo_x.shape[0], dtype=torch.float64) - ) - weights = torch.cat([real_w, pseudo_w], dim=0) - return x, y, weights diff --git a/src/phast/utils/doctor.py b/src/phast/utils/doctor.py index d71e91610..016282495 100644 --- a/src/phast/utils/doctor.py +++ b/src/phast/utils/doctor.py @@ -106,8 +106,6 @@ def build_report() -> str: " spectral/Amor QS on CPU/HPC: install PETSc/MUMPS so backend=auto can select mumps", " cohesive contact: sparse quasi-static backend, backend=auto, normal-contact penalty only when configured", " J2 plasticity: sparse quasi-static backend, backend=auto, guarded supported material combinations", - " dataset/deep learning: Zarr trajectory stores; MP4/raster visualisation when animations are requested", - " learned damage: classical is default; learned_proposal keeps exact correction; learned_replacement is audited experimental inference", "", "Install guidance:", " Start with: pip install -e .", diff --git a/src/phast/utils/io_utils.py b/src/phast/utils/io_utils.py index a9d4d88dd..9aea49c9f 100644 --- a/src/phast/utils/io_utils.py +++ b/src/phast/utils/io_utils.py @@ -2,8 +2,8 @@ Output utilities: HDF5 trajectories, VTU (ParaView), CSV (history), and explicitly selected Zarr trajectory stores. -Includes helpers for GNO (Graph Neural Operator) training data export: -- ``compute_edge_index`` converts triangle connectivity to PyG edge_index. +Includes mesh-graph and run-bookkeeping helpers: +- ``compute_edge_index`` converts triangle connectivity to a COO edge list. - ``write_profiler_csv`` exports solver profiling data. - ``generate_run_tag`` creates informative output directory names. - ``save_run_metadata`` writes a JSON with full run context. @@ -127,7 +127,7 @@ def plot_energy_history(rows, path: str, xlabel: str = "Step") -> None: def compute_edge_index(elements): - """Compute PyG-style edge_index from triangle connectivity. + """Compute a ``(2, n_edges)`` COO edge index from triangle connectivity. For each triangle (i, j, k), creates bidirectional edges: (i,j), (j,i), (j,k), (k,j), (i,k), (k,i). @@ -777,7 +777,7 @@ def _get_step_array(name): def init_h5(h5_path: str, mesh, material): """Create and initialize an H5 file with mesh and metadata. - Stores node coordinates, element connectivity, PyG-compatible + Stores node coordinates, element connectivity, the COO-format mesh ``edge_index``, and mesh size attributes (``n_nodes``, ``n_elements``). Returns @@ -796,7 +796,7 @@ def init_h5(h5_path: str, mesh, material): mesh_grp.create_dataset('element_connectivity', data=mesh.elements.cpu().numpy()) - # PyG-compatible edge_index for GNO training + # Undirected mesh edges in COO (2, n_edges) layout edge_index = compute_edge_index(mesh.elements) mesh_grp.create_dataset('edge_index', data=edge_index) diff --git a/src/phast/workflow/execution.py b/src/phast/workflow/execution.py index d0e50ef42..a96d1799b 100644 --- a/src/phast/workflow/execution.py +++ b/src/phast/workflow/execution.py @@ -558,10 +558,6 @@ def _fracture_bridge_issues(spec: ProblemSpec) -> tuple[str, ...]: solver = spec.solver.parameters defaults = LegacySolverSettings(solver_type=spec.solver.kind) - if solver.get("damage_update", "classical") != "classical" or any( - solver.get(key) for key in ("damage_predictor", "damage_checkpoint", "damage_predictor_options") - ): - issues.append("learned damage execution is not supported by this schema-v2 adapter") if solver.get("adaptive_dt", False): issues.append("adaptive_dt is unsupported by the fixed-time CLI loading/output schedule") try: diff --git a/tests/test_damage_step_context_fem_helpers.py b/tests/test_damage_step_context_fem_helpers.py deleted file mode 100644 index 62dcee382..000000000 --- a/tests/test_damage_step_context_fem_helpers.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Tests for the architecture-neutral finite-element helpers on the context. - -These helpers are shared by every learned-damage predictor, whatever its -architecture, so they are tested here rather than beside any one adapter. -""" -from __future__ import annotations - -import pytest -import torch - -from phast.learned_damage import DamageStepContext - -DTYPE = torch.float64 - -# Unit square split into four triangles around a central node. -NODES = torch.tensor( - [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [0.5, 0.5]], dtype=DTYPE) -ELEMENTS = torch.tensor( - [[0, 1, 4], [1, 2, 4], [2, 3, 4], [3, 0, 4]], dtype=torch.long) - - -def make_context(nodes=NODES, elements=ELEMENTS, *, history=1.0): - n_nodes = nodes.shape[0] - return DamageStepContext( - step=1, - time=0.0, - load_factor=1.0, - nodes=nodes, - elements=elements, - displacement=torch.zeros((n_nodes, 2), dtype=DTYPE), - velocity=torch.zeros((n_nodes, 2), dtype=DTYPE), - history_element=torch.full((elements.shape[0],), history, dtype=DTYPE), - history_nodal=torch.zeros((n_nodes,), dtype=DTYPE), - damage_previous=torch.zeros((n_nodes,), dtype=DTYPE), - material={"Gc": 2.7, "l0": 0.4, "pf_model": "AT2"}, - phase_field_model="AT2", - energy_split="spectral", - device=torch.device("cpu"), - dtype=DTYPE, - ) - - -def test_element_areas_sum_to_the_domain_area(): - assert float(make_context().element_areas().sum()) == pytest.approx(1.0) - - -def test_element_areas_are_orientation_independent(): - flipped = ELEMENTS[:, [0, 2, 1]] - assert torch.allclose( - make_context().element_areas(), - make_context(elements=flipped).element_areas()) - - -def test_assembly_conserves_the_integral(): - context = make_context() - values = torch.tensor([1.0, 2.0, 3.0, 4.0], dtype=DTYPE) - assembled = context.assemble_element_to_nodes(values) - expected = float((values * context.element_areas()).sum()) - assert float(assembled.sum()) == pytest.approx(expected) - - -def test_assembly_of_a_unit_field_returns_the_lumped_mass(): - context = make_context() - lumped = context.assemble_element_to_nodes( - torch.ones(ELEMENTS.shape[0], dtype=DTYPE)) - # The central node belongs to every triangle, so it carries a third of - # the whole domain area. - assert float(lumped[4]) == pytest.approx(1.0 / 3.0) - assert float(lumped.sum()) == pytest.approx(1.0) - - -def test_assembly_rejects_a_wrong_length(): - with pytest.raises(ValueError, match="one entry per element"): - make_context().assemble_element_to_nodes(torch.zeros(2, dtype=DTYPE)) - - -def test_boundary_mask_marks_the_perimeter_and_not_the_interior(): - assert make_context().boundary_node_mask().tolist() == [ - True, True, True, True, False] - - -def test_boundary_mask_detects_an_interior_void(): - # An annulus-like patch: a square ring of eight triangles around a hole. - outer = torch.tensor( - [[0.0, 0.0], [1.0, 0.0], [2.0, 0.0], [2.0, 1.0], - [2.0, 2.0], [1.0, 2.0], [0.0, 2.0], [0.0, 1.0]], dtype=DTYPE) - inner = torch.tensor( - [[0.75, 0.75], [1.25, 0.75], [1.25, 1.25], [0.75, 1.25]], dtype=DTYPE) - nodes = torch.cat((outer, inner), dim=0) - ring = torch.tensor( - [[0, 1, 8], [1, 2, 9], [1, 9, 8], [2, 3, 9], - [3, 4, 10], [3, 10, 9], [4, 5, 10], [5, 6, 11], - [5, 11, 10], [6, 7, 11], [7, 0, 8], [7, 8, 11]], dtype=torch.long) - mask = make_context(nodes, ring).boundary_node_mask() - # Every node lies on either the outer perimeter or the hole. - assert bool(mask.all()) - - -def test_edge_lengths_match_the_geometry(): - context = make_context() - edge_index = torch.tensor([[0, 0], [1, 4]], dtype=torch.long) - lengths = context.edge_lengths(edge_index) - assert float(lengths[0]) == pytest.approx(1.0) - assert float(lengths[1]) == pytest.approx(0.5 * 2.0 ** 0.5) - - -def test_graph_edge_index_is_symmetric_and_duplicate_free(): - edge_index = make_context().graph_edge_index() - pairs = {tuple(pair) for pair in edge_index.t().tolist()} - assert len(pairs) == edge_index.shape[1] - assert all((b, a) in pairs for a, b in pairs) - - -def test_canonical_node_features_have_six_columns(): - features = make_context().canonical_node_features() - assert features.shape == (NODES.shape[0], 6) - - -def test_element_areas_reject_a_non_triangular_mesh(): - quads = torch.tensor([[0, 1, 2, 3]], dtype=torch.long) - with pytest.raises(NotImplementedError, match="three-node triangles"): - make_context(elements=quads).element_areas() diff --git a/tests/test_learned_damage.py b/tests/test_learned_damage.py deleted file mode 100644 index 3611a0e37..000000000 --- a/tests/test_learned_damage.py +++ /dev/null @@ -1,152 +0,0 @@ -from __future__ import annotations - -import pytest -import torch - -from phast.learned_damage import ( - DamagePrediction, - DamagePredictionRejected, - DamageStepContext, - DamageUpdateController, -) - - -class ConstantPredictor: - name = "constant" - - def __init__(self, value): - self.value = value - - def predict(self, context): - return DamagePrediction( - damage=torch.full_like(context.damage_previous, self.value) - ) - - -class IncrementPredictor: - name = "increment" - - def predict(self, context): - return DamagePrediction( - damage=torch.full_like(context.damage_previous, 0.2), - representation="damage_increment", - ) - - -class ResidualSolver: - def __init__(self, target): - self.target = target - - def compute_residual(self, history, damage): - del history - return damage - self.target - - -def make_context(previous=0.2): - dtype = torch.float64 - previous_damage = torch.full((3,), previous, dtype=dtype) - return DamageStepContext( - step=2, - time=0.1, - load_factor=0.5, - nodes=torch.tensor([[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]], dtype=dtype), - elements=torch.tensor([[0, 1, 2]], dtype=torch.long), - displacement=torch.zeros((3, 2), dtype=dtype), - velocity=torch.zeros((3, 2), dtype=dtype), - history_element=torch.zeros((1,), dtype=dtype), - history_nodal=torch.zeros((3,), dtype=dtype), - damage_previous=previous_damage, - material={"pf_model": "AT2", "energy_split": "spectral"}, - phase_field_model="AT2", - energy_split="spectral", - device=torch.device("cpu"), - dtype=dtype, - ) - - -def make_controller(mode, value, target, fallback=True): - controller = DamageUpdateController( - predictor=ConstantPredictor(value), - mode=mode, - fallback=fallback, - residual_rtol=1.0e-6, - residual_atol=1.0e-12, - bound_tolerance=1.0e-8, - ) - solver = ResidualSolver(torch.full((3,), target, dtype=torch.float64)) - return controller, solver - - -def test_proposal_is_projected_and_returned_as_initial_guess(): - context = make_context(previous=0.2) - controller, damage_solver = make_controller( - mode="learned_proposal", value=0.1, target=0.4 - ) - decision = controller.decide(context, damage_solver=damage_solver) - - assert decision.route == "learned_proposal_exact_correction" - assert not decision.accepted_replacement - assert torch.equal(decision.candidate, context.damage_previous) - - -def test_replacement_accepts_audited_prediction(): - context = make_context(previous=0.2) - controller, damage_solver = make_controller( - mode="learned_replacement", value=0.4, target=0.4 - ) - decision = controller.decide(context, damage_solver=damage_solver) - - assert decision.route == "learned_replacement" - assert decision.accepted_replacement - assert torch.allclose( - decision.candidate, - torch.full((3,), 0.4, dtype=torch.float64), - ) - - -def test_replacement_accepts_damage_increment_representation(): - context = make_context(previous=0.2) - controller = DamageUpdateController( - predictor=IncrementPredictor(), - mode="learned_replacement", - fallback=True, - residual_rtol=1.0e-6, - residual_atol=1.0e-12, - bound_tolerance=1.0e-8, - ) - damage_solver = ResidualSolver( - torch.full((3,), 0.4, dtype=torch.float64) - ) - - decision = controller.decide(context, damage_solver=damage_solver) - assert decision.accepted_replacement - assert torch.allclose( - decision.candidate, - torch.full((3,), 0.4, dtype=torch.float64), - ) - - -def test_replacement_rejects_invalid_prediction_and_requests_fallback(): - context = make_context(previous=0.2) - controller, damage_solver = make_controller( - mode="learned_replacement", value=float("nan"), target=0.4 - ) - decision = controller.decide(context, damage_solver=damage_solver) - - assert decision.route == "classical_fallback" - assert not decision.accepted_replacement - assert decision.candidate is None - assert "NaN" in decision.reason - - -def test_replacement_can_raise_instead_of_falling_back(): - context = make_context(previous=0.2) - controller, damage_solver = make_controller( - mode="learned_replacement", - value=float("nan"), - target=0.4, - fallback=False, - ) - - with pytest.raises(DamagePredictionRejected): - controller.decide(context, damage_solver=damage_solver) diff --git a/tests/test_mesh_graph_net_adapter.py b/tests/test_mesh_graph_net_adapter.py deleted file mode 100644 index 170cb3a35..000000000 --- a/tests/test_mesh_graph_net_adapter.py +++ /dev/null @@ -1,251 +0,0 @@ -"""Tests for the mesh-graph-network learned-damage adapter. - -The adapter is one architecture among several. What is checked here is the -wrapper: its feature contract, its checkpoint handling and its conformance to -the predictor protocol. The finite-element helpers it builds on are covered by -``test_damage_step_context_fem_helpers``. -""" -from __future__ import annotations - -import pytest -import torch - -from phast.learned_damage import DamageStepContext - -from examples.learned_damage.architectures.mesh_graph_net import ( - MeshGraphDamageNet, - MeshGraphNetPredictor, - bounding_box_indicator, - create_predictor, - export_torchscript, - load_network, -) - -DTYPE = torch.float64 - -NODES = torch.tensor( - [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [0.5, 0.5]], dtype=DTYPE) -ELEMENTS = torch.tensor( - [[0, 1, 4], [1, 2, 4], [2, 3, 4], [3, 0, 4]], dtype=torch.long) - - -def make_context(*, history=1.0, Gc=2.7, l0=0.4): - n_nodes = NODES.shape[0] - return DamageStepContext( - step=1, - time=0.0, - load_factor=1.0, - nodes=NODES, - elements=ELEMENTS, - displacement=torch.zeros((n_nodes, 2), dtype=DTYPE), - velocity=torch.zeros((n_nodes, 2), dtype=DTYPE), - history_element=torch.full((ELEMENTS.shape[0],), history, dtype=DTYPE), - history_nodal=torch.zeros((n_nodes,), dtype=DTYPE), - damage_previous=torch.zeros((n_nodes,), dtype=DTYPE), - material={"Gc": Gc, "l0": l0, "pf_model": "AT2"}, - phase_field_model="AT2", - energy_split="spectral", - device=torch.device("cpu"), - dtype=DTYPE, - ) - - -def make_net(): - torch.manual_seed(0) - return MeshGraphDamageNet( - latent_dim=4, hidden_layers=1, message_passing_steps=2) - - -def random_graph_inputs(context): - edge_index = context.graph_edge_index() - return ( - torch.rand(NODES.shape[0], 2), - edge_index, - torch.rand(edge_index.shape[1], 1), - ) - - -def save_state_dict(path, net): - torch.save( - { - "model_state_dict": net.state_dict(), - "configuration": { - "latent_dim": 4, "hidden_layers": 1, "message_passing_steps": 2}, - "completed_epochs": 3, - }, - path, - ) - return path - - -# ---------------------------------------------------------------- network - -def test_network_returns_one_value_per_node(): - net = make_net() - with torch.no_grad(): - output = net(*random_graph_inputs(make_context())) - assert output.shape == (NODES.shape[0],) - - -def test_network_is_scriptable_and_agrees_with_the_eager_model(): - net = make_net().eval() - inputs = random_graph_inputs(make_context()) - scripted = torch.jit.script(net) - with torch.no_grad(): - assert torch.equal(net(*inputs), scripted(*inputs)) - - -# ---------------------------------------------------------------- features - -def test_bounding_box_indicator_ignores_the_interior(): - assert bounding_box_indicator(NODES).tolist() == [1.0, 1.0, 1.0, 1.0, 0.0] - - -def test_node_features_use_the_assembled_normalized_history(): - predictor = MeshGraphNetPredictor(make_net()) - context = make_context(history=1.0, Gc=2.7, l0=0.4) - - features = predictor.node_features(context) - expected = context.assemble_element_to_nodes( - context.history_element / (2.7 * 0.4)) - - assert features.shape == (NODES.shape[0], 2) - assert torch.allclose(features[:, 0], expected) - assert torch.allclose(features[:, 1], bounding_box_indicator(NODES)) - - -def test_topological_boundary_indicator_can_be_selected(): - context = make_context() - predictor = MeshGraphNetPredictor( - make_net(), boundary_indicator="topological") - features = predictor.node_features(context) - assert torch.allclose( - features[:, 1], context.boundary_node_mask().to(DTYPE)) - - -def test_an_unknown_boundary_indicator_is_rejected(): - with pytest.raises(ValueError, match="bounding_box.*topological"): - MeshGraphNetPredictor(make_net(), boundary_indicator="guess") - - -def test_overrides_take_precedence_over_the_running_material(): - context = make_context(Gc=2.7, l0=0.4) - default = MeshGraphNetPredictor(make_net()).node_features(context) - overridden = MeshGraphNetPredictor( - make_net(), Gc=1.0, length_scale=1.0).node_features(context) - assert not torch.allclose(default[:, 0], overridden[:, 0]) - - -def test_a_material_without_the_normalization_constants_is_rejected(): - stripped = DamageStepContext( - **{**make_context().__dict__, "material": {"pf_model": "AT2"}}) - with pytest.raises(ValueError, match="exposes no 'Gc'"): - MeshGraphNetPredictor(make_net()).node_features(stripped) - - -def test_graph_is_cached_between_calls_on_one_mesh(): - predictor = MeshGraphNetPredictor(make_net()) - context = make_context() - assert predictor._graph(context)[0] is predictor._graph(context)[0] - - -def test_graph_cache_invalidates_for_a_different_same_sized_mesh(): - predictor = MeshGraphNetPredictor(make_net()) - context = make_context() - first_lengths = predictor._graph(context)[1] - scaled = DamageStepContext( - **{**context.__dict__, "nodes": context.nodes * 2.0}) - second_lengths = predictor._graph(scaled)[1] - assert not torch.equal(first_lengths, second_lengths) - - -# ---------------------------------------------------------------- protocol - -def test_predict_returns_one_nodal_value_in_the_context_dtype(): - context = make_context() - prediction = MeshGraphNetPredictor(make_net()).predict(context) - assert prediction.damage.shape == context.damage_previous.shape - assert prediction.damage.dtype == context.dtype - assert prediction.diagnostics["adapter"] == "mesh-graph-net" - assert prediction.diagnostics["boundary_indicator"] == "bounding_box" - - -def test_increment_representation_is_passed_through(): - predictor = MeshGraphNetPredictor( - make_net(), representation="damage_increment") - assert predictor.predict(make_context()).representation == "damage_increment" - - -# ---------------------------------------------------------------- checkpoints - -def test_state_dict_checkpoint_round_trips(tmp_path): - net = make_net().eval() - loaded, metadata = load_network(save_state_dict(tmp_path / "ck.pt", net)) - - assert metadata["format"] == "state_dict" - assert metadata["completed_epochs"] == 3 - - inputs = random_graph_inputs(make_context()) - with torch.no_grad(): - assert torch.equal(net(*inputs), loaded(*inputs)) - - -def test_a_checkpoint_without_architecture_entries_is_rejected(tmp_path): - path = tmp_path / "incomplete.pt" - torch.save({"model_state_dict": make_net().state_dict()}, path) - with pytest.raises(ValueError, match="missing architecture entries"): - load_network(path) - - -def test_an_unrecognized_file_is_rejected(tmp_path): - path = tmp_path / "unrelated.pt" - torch.save({"something": 1}, path) - with pytest.raises(ValueError, match="neither a TorchScript archive"): - load_network(path) - - -def test_torchscript_export_loads_and_predicts(tmp_path): - net = make_net().eval() - archive = export_torchscript( - save_state_dict(tmp_path / "ck.pt", net), - tmp_path / "scripted.pt", - n_nodes=16, - ) - - loaded, metadata = load_network(archive) - assert metadata["format"] == "torchscript" - - inputs = random_graph_inputs(make_context()) - with torch.no_grad(): - assert torch.equal(net(*inputs), loaded(*inputs)) - - predictor = create_predictor(checkpoint=archive, device="cpu", options={}) - assert predictor.predict(make_context()).damage.shape == (NODES.shape[0],) - - -def test_exporting_an_archive_again_is_rejected(tmp_path): - archive = export_torchscript( - save_state_dict(tmp_path / "ck.pt", make_net().eval()), - tmp_path / "scripted.pt", - n_nodes=16, - ) - with pytest.raises(ValueError, match="already a TorchScript archive"): - export_torchscript(archive, tmp_path / "again.pt") - - -def test_factory_passes_options_through(tmp_path): - archive = export_torchscript( - save_state_dict(tmp_path / "ck.pt", make_net().eval()), - tmp_path / "scripted.pt", - n_nodes=16, - ) - predictor = create_predictor( - checkpoint=archive, - device="cpu", - options={ - "representation": "damage_increment", - "boundary_indicator": "topological", - }, - ) - assert predictor.representation == "damage_increment" - assert predictor.boundary_indicator == "topological" diff --git a/tests/test_standard_workflow.py b/tests/test_standard_workflow.py index 3f35711be..1fff9816d 100644 --- a/tests/test_standard_workflow.py +++ b/tests/test_standard_workflow.py @@ -161,7 +161,7 @@ def test_automatic_cfl_loading_and_hdf5_default(tmp_path: Path) -> None: @pytest.mark.parametrize("case", [ "multiple_steps", "step_mismatch", "multimaterial_dynamic", "partial_material", "geometric_region", "initial_velocity", "initial_damage_range", "unknown_solver", - "learned", "implicit_integrator", "adaptive_dt", "unused_backend", "unknown_control", + "implicit_integrator", "adaptive_dt", "unused_backend", "unknown_control", "missing_dt", "both_time_limits", "fixed_dt_and_cfl", "unknown_geometry", "3d", "higher_order", "material_typo", "negative_density", "nonfinite_material", "unknown_output", "vtu", "field_without_trajectory", "field_cadence", @@ -185,9 +185,9 @@ def test_unsupported_choices_fail_before_execution(case: str, tmp_path: Path, mo raw["initial_conditions"][0]["field"] = "velocity" elif case == "initial_damage_range": raw["initial_conditions"][0]["value"] = 2.0 - elif case in {"unknown_solver", "learned", "implicit_integrator", "adaptive_dt", "unused_backend"}: + elif case in {"unknown_solver", "implicit_integrator", "adaptive_dt", "unused_backend"}: key, value = { - "unknown_solver": ("damage_toll", 1.0e-6), "learned": ("damage_update", "learned_proposal"), + "unknown_solver": ("damage_toll", 1.0e-6), "implicit_integrator": ("time_integrator", "generalized_alpha"), "adaptive_dt": ("adaptive_dt", True), "unused_backend": ("backend", "scipy"), }[case]