From e308d7582af6e868dc9153215631d43c7707e427 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Fri, 25 Sep 2026 15:22:28 -0700 Subject: [PATCH 1/9] describe() as the one structured introspection; view() renders it in any form Every core object now says what it is once, as data, and one renderer shows it. describe() returns a plain tree of kind, name and summary with facts, terms, forms, conditions and children as the object has them; uw.render turns the tree into Markdown with mathematics, plain text, a LaTeX fragment, YAML or JSON, and view() is that render in the form the session wants. The solver already described itself for the transcript; constitutive models, time histories, meshes, mesh and swarm variables, swarms and the model join it, and the twenty-five notebook-only _object_viewer methods they carried are gone. A class without a description still falls back to its viewer. Along the way: the Model class carried two view() methods, the second silently overriding the first; a history's description now names its scheme, order and weighting, which is the method metadata a run record needs; the transcript's part record no longer takes the description's own kind or children; a constitutive model lists each parameter once, since a property alias is the same descriptor under another name. Contract test for every kind in every format, with the serial formats round-tripping and the transcript's part record unchanged (test_0017); the developer page describe-and-view.md. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V --- docs/developer/index.md | 1 + .../developer/subsystems/describe-and-view.md | 97 +++++ src/underworld3/__init__.py | 1 + src/underworld3/constitutive_models.py | 189 +++------ .../cython/petsc_generic_snes_solvers.pyx | 192 +-------- .../discretisation/discretisation_mesh.py | 123 ++---- .../discretisation_mesh_variables.py | 48 +-- .../discretisation/enhanced_variables.py | 58 +-- src/underworld3/model.py | 375 +++--------------- src/underworld3/swarm.py | 63 ++- .../systems/advection_diffusion_eulerian.py | 12 - src/underworld3/systems/ddt.py | 84 ++-- src/underworld3/systems/solvers.py | 27 -- src/underworld3/utilities/_api_tools.py | 90 ++--- src/underworld3/utilities/describe.py | 367 +++++++++++++++++ tests/test_0017_describe_and_render.py | 126 ++++++ ...t_0857_lagrangian_ddt_advecting_history.py | 4 +- 17 files changed, 921 insertions(+), 936 deletions(-) create mode 100644 docs/developer/subsystems/describe-and-view.md create mode 100644 src/underworld3/utilities/describe.py create mode 100644 tests/test_0017_describe_and_render.py diff --git a/docs/developer/index.md b/docs/developer/index.md index 16eff5b9d..3449410c7 100644 --- a/docs/developer/index.md +++ b/docs/developer/index.md @@ -208,6 +208,7 @@ subsystems/expressions-functions subsystems/containers subsystems/checkpointing-system subsystems/model-orchestration +subsystems/describe-and-view subsystems/jit-cache ``` diff --git a/docs/developer/subsystems/describe-and-view.md b/docs/developer/subsystems/describe-and-view.md new file mode 100644 index 000000000..f6e27b2a5 --- /dev/null +++ b/docs/developer/subsystems/describe-and-view.md @@ -0,0 +1,97 @@ +# Descriptions and views + +Every core object says what it is once, as data, and one renderer shows it. +`describe()` returns a plain tree; `view()` renders that tree for wherever +you are; `uw.render(...)` gives the same tree as a string in a named form. +The run transcript serialises the same tree when a solver acts, so a +notebook, a note, a run record and a query cannot disagree about what an +object is. + +## The record + +```python +d = stokes.describe() # a dict +d["kind"], d["name"], d["summary"] +``` + +Every description carries `kind`, `name` and `summary`. The rest depends +on what the object holds: + +| key | holds | +|---|---| +| `facts` | scalar facts, `{label: value}`, in a stable order | +| `terms` | the named quantities the object was given: `name`, `symbol`, `latex`, `text`, `units`, `description`, and `where` | +| `forms` | its equations, `{name: {symbol, latex, text, description, where}}` | +| `conditions` | its boundary conditions (`boundary_conditions` on a solver, the transcript's name for the same list) | +| `children` | the objects it contains, each a description of its own | + +`where` is the list of named expressions inside a value, each carrying the +same keys as a term and its own `where`, followed to the `depth` asked +for. Only strings, numbers, lists and dicts appear in the tree, so it can +be written as YAML or JSON without further work. The live SymPy objects +stay on the object. + +The kinds and what they contain: + +| kind | children | facts worth knowing | +|---|---|---| +| `solver` | its constitutive model and its histories | `unknown`, `dim`; `forms` are `F0`, `F1`, `PF0` as implemented | +| `constitutive_model` | none | parameters as terms; the flux as a form | +| `history` | none | `scheme`, `order`, `theta`: the time integrator a part used | +| `mesh` | its variables | dimension, cells, coordinate system, units, boundaries, cell quality | +| `variable`, `swarm_variable` | none | symbol, shape, degree, continuity, type, units, proxy | +| `swarm` | its variables | particle count | +| `model` | meshes, swarms, solvers | scales as declared, clock, counts | + +## Rendering + +```python +stokes.view() # Markdown with mathematics in a notebook, text in a terminal +stokes.view(format="latex") # a fragment for a note +stokes.view(format="yaml") # the record, for a query or a tool +uw.render(stokes.describe(), "markdown", depth=1) +``` + +The formats are `markdown`, `text`, `latex`, `yaml` and `json`. `depth` +limits how many levels of children are shown; `describe(depth=...)` limits +how far named expressions are followed into each other. A solver with a +constitutive model written in terms of further named quantities renders +them as a nested "where" list under each form. + +`view()` on a class, or `view(class_documentation=True)` on an instance, +shows the class documentation as well. + +## Adding a description to a class + +Override `describe(self, depth=4)` and return a record built with +`underworld3.utilities.describe.record` and `term`: + +```python +from underworld3.utilities.describe import record, term + +def describe(self, depth=4): + facts = {"order": self.order} + terms = [term("psi", self.psi_fn, description="the quantity tracked")] + return record("history", type(self).__name__, "a time history", facts=facts, terms=terms) +``` + +`term()` takes a SymPy expression, a named Underworld expression (whose +symbol, units and description it reads), a quantity or a number. Nothing +else is needed: `view()` finds the description and renders it, and the +contract test (`test_0017`) checks that every kind carries the shared keys +and renders in every format. + +A class without `describe()` falls back to its `_object_viewer()`, the +notebook-only display from before this layer existed. New classes should +not add one. + +## Where the same tree goes + +- The transcript writes a solver's description as its `part` record when + the solver first acts and again if its forms change; `uw.transcript_key` + renders those records. A part record is a `kind: part` and carries the + solver's `forms`, `boundary_conditions` and `terms`; the solver's own + `kind` and `children` are left out, since the children record themselves + when they act. +- A query interface or an MCP tool returns `describe()` as YAML or JSON + with nothing added; `depth=0` is the summary, deeper is the detail. diff --git a/src/underworld3/__init__.py b/src/underworld3/__init__.py index 4bfc22d3c..3d2385597 100644 --- a/src/underworld3/__init__.py +++ b/src/underworld3/__init__.py @@ -222,6 +222,7 @@ def view(): ThermalConvectionConfig, create_thermal_convection_model, ) +from .utilities.describe import render from .utilities.transcript_report import ( transcript_diagram, transcript_flowchart, diff --git a/src/underworld3/constitutive_models.py b/src/underworld3/constitutive_models.py index fe8764c86..0d1067aee 100644 --- a/src/underworld3/constitutive_models.py +++ b/src/underworld3/constitutive_models.py @@ -739,15 +739,62 @@ def _build_c_tensor(self): return - def _object_viewer(self): - from IPython.display import Latex, Markdown, display - from textwrap import dedent + def describe(self, depth=4): + """What this constitutive model is, as data: its parameters as terms, + with the named expressions inside each followed to ``depth``, and + the flux it defines as a form where it can be formed.""" + import sympy + from underworld3.utilities.describe import record, term + + def unpack(expression, level, seen): + out = [] + if level > depth or expression is None: + return out + try: + found = list(uw.function.fn_extract_expressions(expression)) + except Exception: + return out + for named in sorted(found, key=lambda e: str(getattr(e, "symbol", e))): + symbol = str(getattr(named, "symbol", named)) + if symbol in seen: + continue + seen.add(symbol) + entry = term(symbol, named) + entry["where"] = unpack(getattr(named, "sym", None), level + 1, seen) + out.append(entry) + return out + + terms = [] + params = getattr(self, "Parameters", None) + if params is not None: + from underworld3.utilities._api_tools import ExpressionDescriptor + names = [] + for cls in type(params).__mro__: + for key, attr in cls.__dict__.items(): + if isinstance(attr, ExpressionDescriptor) and key not in names: + names.append(key) # a property is an alias of one of these + for name in names: + try: + value = getattr(params, name) + except Exception: + continue + if not (hasattr(value, "sym") and hasattr(value, "symbol")): + continue + entry = term(name, value) + entry["where"] = unpack(value.sym, 1, set()) + terms.append(entry) + forms = {} + try: + flux = self.flux + forms["flux"] = {"symbol": r"\mathbf{F}", "description": "the flux this model defines", + "latex": sympy.latex(flux), "text": str(flux), "where": []} + except Exception: + pass + doc = (type(self).__doc__ or "").strip().split("\n")[0] + return record("constitutive_model", type(self).__name__, doc, + facts={"dimension": getattr(self, "dim", None)}, + terms=terms, forms=forms or None) - display( - Markdown( - rf"This consititutive model is formulated for {self.dim} dimensional equations" - ) - ) class ViscousFlowModel(Constitutive_Model): @@ -958,17 +1005,6 @@ def _build_c_tensor(self): return - def _object_viewer(self): - from IPython.display import Latex, Markdown, display - - super()._object_viewer() - - ## feedback on this instance - display( - Latex( - r"$\quad\eta_\textrm{eff} = $ " + sympy.sympify(self.viscosity.sym)._repr_latex_() - ) - ) # --- Yield soft-min smoother (shared by the visco-plastic subclasses) ----------- # The δ soft-min regularisation and the smooth-min FAMILY selection live on the @@ -1640,24 +1676,6 @@ def plastic_correction(self) -> float: return correction - def _object_viewer(self): - from IPython.display import Latex, Markdown, display - - super()._object_viewer() - - ## feedback on this instance - display( - Latex( - r"$\quad\eta_\textrm{0} = $" - + sympy.sympify(self.Parameters.shear_viscosity_0.sym)._repr_latex_() - ), - Latex( - r"$\quad\tau_\textrm{y} = $" - + sympy.sympify(self.Parameters.yield_stress.sym)._repr_latex_(), - ), - ) - - return class ViscoElasticPlasticFlowModel(ViscousFlowModel): @@ -2446,38 +2464,6 @@ def stress(self): # return edot_inv_II - def _object_viewer(self): - from IPython.display import Latex, Markdown, display - - # super()._object_viewer() - - display(Markdown(r"### Viscous deformation")) - display( - Latex( - r"$\quad\eta_\textrm{0} = $ " - + sympy.sympify(self.Parameters.shear_viscosity_0.sym)._repr_latex_() - ), - ) - - display(Markdown(r"#### Elastic deformation")) - display( - Latex( - r"$\quad\mu = $ " + sympy.sympify(self.Parameters.shear_modulus.sym)._repr_latex_(), - ), - Latex( - r"$\quad\Delta t_e = $ " - + sympy.sympify(self.Parameters.dt_elastic.sym)._repr_latex_(), - ), - ) - - display(Markdown(r"#### Plastic deformation")) - display( - Latex( - r"$\quad\tau_\textrm{y} = $ " - + sympy.sympify(self.Parameters.yield_stress.sym)._repr_latex_(), - ) - ## Todo: add all the other properties in here - ) @property def yield_mode(self): @@ -2707,17 +2693,6 @@ def _build_c_tensor(self): return - def _object_viewer(self): - from IPython.display import Latex, Markdown, display - - super()._object_viewer() - - ## feedback on this instance - display( - Latex(r"$\quad\kappa = $ " + sympy.sympify(self.Parameters.diffusivity)._repr_latex_()) - ) - - return # AnisotropicDiffusionModel: expects a diffusivity vector and builds a diagonal tensor. @@ -2778,15 +2753,6 @@ def _build_c_tensor(self): self._c = self.Parameters.diffusivity self._is_setup = True - def _object_viewer(self): - from IPython.display import Latex, display - - super()._object_viewer() - - diagonal = self.Parameters.diffusivity.diagonal() - latex_entries = ", ".join([sympy.latex(k) for k in diagonal]) - kappa_latex = r"\kappa = \mathrm{diag}\left(" + latex_entries + r"\right)" - display(Latex(r"$\quad " + kappa_latex + r"$")) class GenericFluxModel(Constitutive_Model): @@ -2859,14 +2825,6 @@ def flux(self): # raise RuntimeError("Flux expression has not been set.") return self.Parameters.flux - def _object_viewer(self): - from IPython.display import display, Latex - - super()._object_viewer() - if self.flux is not None: - display(Latex(r"$\vec{q} = " + sympy.latex(self.flux) + "$")) - else: - display(Latex(r"No flux expression set.")) class DarcyFlowModel(Constitutive_Model): @@ -2992,17 +2950,6 @@ def _build_c_tensor(self): return - def _object_viewer(self): - from IPython.display import Latex, Markdown, display - - super()._object_viewer() - - ## feedback on this instance - display( - Latex(r"$\quad\kappa = $ " + sympy.sympify(self.Parameters.diffusivity)._repr_latex_()) - ) - - return @property def flux(self): @@ -3216,20 +3163,6 @@ def _build_c_tensor(self): return - def _object_viewer(self): - from IPython.display import Latex, Markdown, display - - super()._object_viewer() - - ## feedback on this instance - display(Latex(r"$\quad\eta_0 = $ " + sympy.sympify(self.Parameters.shear_viscosity_0)._repr_latex_())) - display(Latex(r"$\quad\eta_1 = $ " + sympy.sympify(self.Parameters.shear_viscosity_1)._repr_latex_())) - display( - Latex( - r"$\quad\hat{\mathbf{n}} = $ " - + sympy.sympify(self.Parameters.director.T)._repr_latex_() - ) - ) class TransverseIsotropicVEPFlowModel(TransverseIsotropicFlowModel): @@ -4700,15 +4633,3 @@ def K(self): # Return harmonic average return 1 / combined_inv_K - def _object_viewer(self): - from IPython.display import Latex, Markdown, display - - super()._object_viewer() - - display(Markdown(f"**Multi-Material Model**: {len(self._constitutive_models)} materials")) - - for i, model in enumerate(self._constitutive_models): - display(Markdown(f"**Material {i}**: {type(model).__name__}")) - - if self.flux is not None: - display(Latex(r"$\mathbf{f}_{\text{composite}} = " + sympy.latex(self.flux) + "$")) diff --git a/src/underworld3/cython/petsc_generic_snes_solvers.pyx b/src/underworld3/cython/petsc_generic_snes_solvers.pyx index 3f7ec1426..c58133061 100644 --- a/src/underworld3/cython/petsc_generic_snes_solvers.pyx +++ b/src/underworld3/cython/petsc_generic_snes_solvers.pyx @@ -1604,36 +1604,36 @@ class SolverBaseClass(uw_object): "where": unpack(value, 1, set()) if value is not None else [], }) + unknown = getattr(getattr(self, "u", None), "name", None) + dim = getattr(self.mesh, "dim", None) + summary = f"{type(self).__name__}" + (f" for {unknown}" if unknown else "") + (f", {dim}-D" if dim else "") + # what the solver contains: its constitutive model and its histories, + # each describing itself one level down + children = [] + for child in (getattr(self, "constitutive_model", None), + getattr(self, "DuDt", None), getattr(self, "DFDt", None)): + if child is None or not hasattr(child, "describe"): + continue + try: + children.append(child.describe(depth=max(depth - 1, 0))) + except Exception: + continue return { + "kind": "solver", + "name": type(self).__name__, + "summary": summary, "solver": type(self).__name__, - "unknown": getattr(getattr(self, "u", None), "name", None), - "dim": getattr(self.mesh, "dim", None), + "unknown": unknown, + "dim": dim, "cdim": getattr(self.mesh, "cdim", None), "forms": forms, "boundary_conditions": conditions, "terms": described_terms, "terms_declared": terms is not None, + "children": children, } - def _describe_where(self, entries, display, Latex, level=0): - """Render the "Where:" tree from :meth:`describe`.""" - for entry in entries: - indent = "\\quad " * (level + 1) - tail = f" \\quad ({entry['description']})" if entry["description"] else "" - display(Latex(f"${indent}{entry['symbol']} = {entry['latex']}${tail}")) - self._describe_where(entry.get("where", []), display, Latex, level + 1) - - def _object_viewer(self): - '''This will add specific information about this object to the generic class viewer - ''' - from IPython.display import Latex, Markdown, display - from textwrap import dedent - - display(Markdown(fr"### Boundary Conditions")) - display(Markdown(fr"This solver is formulated as {self.mesh.dim} dimensional problem with a {self.mesh.cdim} dimensional mesh")) - - return def _reset_rotated_solver_cache(self): """Release the rotated-free-slip cross-solve workspace (rotated_bc @@ -4446,50 +4446,6 @@ class SNES_Scalar(SolverBaseClass): return - def _object_viewer(self): - '''This will add specific information about this object to the generic class viewer - ''' - from IPython.display import Latex, Markdown, display - from textwrap import dedent - - f0 = self.F0.sym - F1 = self.F1.sym - - eqF1 = "$\\tiny \\quad \\nabla \\cdot \\color{Blue}" + sympy.latex( F1 )+"$ + " - eqf0 = "$\\tiny \\phantom{ \\quad \\nabla \\cdot} \\color{DarkRed}" + sympy.latex( f0 )+"\\color{Black} = 0 $" - - # feedback on this instance - display( - Markdown(f"# Underworld / PETSc General Scalar Equation Solver"), - Markdown(f"Primary problem: "), - Latex(eqF1), Latex(eqf0), - ) - - - # Rendered from describe(), so the "Where:" a reader sees and the - # equation the run transcript records come from one description. - where = [] - for form in self.describe()["forms"].values(): - where.extend(form.get("where", [])) - if where: - display(Markdown("*Where:*")) - self._describe_where(where, display, Latex) - - - display( - Markdown(fr"# Boundary Conditions"),) - - bc_table = "| Type | Boundary | Expression | \n" - bc_table += "|:------------------------ | -------- | ---------- | \n" - - for bc in self.essential_bcs: - bc_table += f"| **{bc.type}** | {bc.boundary} | ${sympy.latex(bc.fn.T)} $ | \n" - for bc in self.natural_bcs: - bc_table += f"| **{bc.type}** | {bc.boundary} | ${sympy.latex(bc.fn_f.T)} $ | \n" - - display(Markdown(bc_table)) - - display(Markdown(fr"This solver is formulated as a {self.mesh.dim} dimensional problem with a {self.mesh.cdim} dimensional mesh")) @@ -5519,48 +5475,6 @@ class SNES_Vector(SolverBaseClass): return - def _object_viewer(self): - '''This will add specific information about this object to the generic class viewer - ''' - from IPython.display import Latex, Markdown, display - from textwrap import dedent - - f0 = self.F0.sym - F1 = self.F1.sym - - eqF1 = "$\\tiny \\quad \\nabla \\cdot \\color{Blue}" + sympy.latex( F1 )+"$ + " - eqf0 = "$\\tiny \\phantom{ \\quad \\nabla \\cdot} \\color{DarkRed}" + sympy.latex( f0 )+"\\color{Black} = 0 $" - - # feedback on this instance - display( - Markdown(f"# Underworld / PETSc General Vector Equation Solver"), - Markdown(f"Primary problem: "), - Latex(eqF1), Latex(eqf0), - ) - - # Rendered from describe(), so the "Where:" a reader sees and the - # equation the run transcript records come from one description. - where = [] - for form in self.describe()["forms"].values(): - where.extend(form.get("where", [])) - if where: - display(Markdown("*Where:*")) - self._describe_where(where, display, Latex) - - display( - Markdown(fr"# Boundary Conditions"),) - - bc_table = "| Type | Boundary | Expression | \n" - bc_table += "|:------------------------ | -------- | ---------- | \n" - - for bc in self.essential_bcs: - bc_table += f"| **{bc.type}** | {bc.boundary} | ${sympy.latex(bc.fn.T)} $ | \n" - for bc in self.natural_bcs: - bc_table += f"| **{bc.type}** | {bc.boundary} | ${sympy.latex(bc.fn_f.T)} $ | \n" - - display(Markdown(bc_table)) - - display(Markdown(fr"This solver is formulated as a {self.mesh.dim} dimensional problem with a {self.mesh.cdim} dimensional mesh")) ### ================================= @@ -6218,20 +6132,6 @@ class SNES_MultiComponent(SolverBaseClass): return - def _object_viewer(self): - from IPython.display import Latex, Markdown, display - - f0 = self.F0.sym - F1 = self.F1.sym - - eqF1 = "$\\tiny \\quad \\nabla \\cdot \\color{Blue}" + sympy.latex(F1) + "$ + " - eqf0 = "$\\tiny \\phantom{ \\quad \\nabla \\cdot} \\color{DarkRed}" + sympy.latex(f0) + "\\color{Black} = 0 $" - - display( - Markdown(f"# Underworld / PETSc General Multi-Component Solver ({self._n_components} components)"), - Markdown(f"Primary problem: "), - Latex(eqF1), Latex(eqf0), - ) ### ================================= @@ -8075,58 +7975,6 @@ class SNES_Stokes_SaddlePt(SolverBaseClass): # redundant uf0/uF1 aliases used below); settle one scheme rather than # adding new spellings. - def _object_viewer(self): - '''This will add specific information about this object to the generic class viewer - ''' - from IPython.display import Latex, Markdown, display - from textwrap import dedent - - uf0 = self.F0.sym - uF1 = self.F1.sym - pF0 = self.PF0.sym - - if self.penalty.sym == 0: - uF1 = self.F1.sym.subs(self.penalty, self.penalty.sym) - - eqF1 = "$\\tiny \\quad \\nabla \\cdot \\color{Blue}" + sympy.latex( uF1 )+"$ + " - eqf0 = "$\\tiny \\phantom{ \\quad \\nabla \\cdot} \\color{DarkRed}" + sympy.latex( uf0 )+"\\color{Black} = 0 $" - eqp0 = "$\\tiny \\phantom{ \\quad \\nabla \\cdot} " + sympy.latex( pF0 ) + " = 0 $" - - # feedback on this instance - display( - Markdown(f"# Underworld / PETSc General Saddle Point Equation Solver"), - Markdown(f"Primary problem: "), - Latex(eqF1), Latex(eqf0), - Markdown(f"Constraint: "), - Latex(eqp0 ), - ) - - exprs = uw.function.fn_extract_expressions(self.F0) - exprs = exprs.union(uw.function.fn_extract_expressions(self.F1)) - exprs = exprs.union(uw.function.fn_extract_expressions(self.PF0)) - - if len(exprs) != 0: - display(Markdown("*Where:*")) - - for expr in exprs: - expr._object_viewer() - - display( - Markdown(fr"# Boundary Conditions"),) - - bc_table = "| Type | Boundary | Expression | \n" - bc_table += "|:------------------------ | -------- | ---------- | \n" - - for bc in self.essential_bcs: - bc_table += f"| **{bc.type}** | {bc.boundary} | ${sympy.latex(bc.fn.T)} $ | \n" - for bc in self.natural_bcs: - bc_table += f"| **{bc.type}** | {bc.boundary} | ${sympy.latex(bc.fn_f.T)} $ | \n" - - display(Markdown(bc_table)) - - display(Markdown(fr"This solver is formulated as a {self.mesh.dim} dimensional problem with a {self.mesh.cdim} dimensional mesh")) - - return def validate_solver(self): """Checks to see if the required properties have been set""" diff --git a/src/underworld3/discretisation/discretisation_mesh.py b/src/underworld3/discretisation/discretisation_mesh.py index bf26a7f88..2c91a9992 100644 --- a/src/underworld3/discretisation/discretisation_mesh.py +++ b/src/underworld3/discretisation/discretisation_mesh.py @@ -1767,7 +1767,46 @@ def _print_boundary_table(self, with_sizes=False): uw.pprint("\n") - def view(self, level=0): + def describe(self, depth=4): + """What this mesh is, as data: dimension, coordinate system, size, + units and boundaries, with its variables as children.""" + from underworld3.utilities.describe import record + facts = {"dimension": self.dim, "coordinate dimension": self.cdim} + try: + facts["coordinate system"] = self.CoordinateSystem.coordinate_type.name + except Exception: + pass + try: + nstart, nend = self.dm.getHeightStratum(0) + facts["cells"] = int(nend - nstart) + except Exception: + pass + units = getattr(self, "units", None) + if units: + facts["coordinate units"] = str(units) + try: + facts["boundaries"] = [b.name for b in self.boundaries] + except Exception: + pass + try: + Q = self.quality() + if Q.get("element") == "2D-simplex": + facts["cell quality"] = (f"q_min {Q['q_min']:.3f}, mean {Q['q_mean']:.2f}, " + f"{Q['n_q_lt_0p3']} cells below 0.3") + except Exception: + pass + children = [] + if depth > 0: + for var in list(self.vars.values()): + if hasattr(var, "describe"): + try: + children.append(var.describe(depth=depth - 1)) + except Exception: + continue + summary = f"{self.dim}-D mesh" + (f", {facts['cells']} cells" if "cells" in facts else "") + return record("mesh", getattr(self, "name", None), summary, facts=facts, children=children) + + def view(self, level=0, format=None): """ Displays mesh information at different levels. @@ -1781,87 +1820,11 @@ def view(self, level=0): import numpy as np if level == 0: - uw.pprint(f"\n") - uw.pprint(f"Mesh # {self.instance}: {self.name}\n") - - # Display coordinate units if set - if hasattr(self, "units") and self.units is not None: - uw.pprint(f"Coordinate units: {self.units}\n") - uw.pprint(f" Access unit-aware coordinates via: mesh.X.coords\n") - uw.pprint(f" Query units with: uw.get_units(mesh.X.coords)\n") - - # Display length scale for non-dimensionalization - if hasattr(self, "_length_scale"): - if self._length_scale != 1.0: - uw.pprint( - f"Length scale (non-dimensionalization): {self._length_scale} {self._length_units}\n" - ) - else: - uw.pprint(f"Length scale: 1.0 (no scaling)\n") - - # Display coordinate system information - coord_sys = self.CoordinateSystem - coord_type = coord_sys.coordinate_type - uw.pprint(f"Coordinate system: {coord_type.name}\n") - - # Show available coordinate accessors - accessors = ["mesh.X.coords (Cartesian)"] # Always available - if coord_sys._spherical_accessor is not None: - if self.dim == 2: - accessors.append("mesh.X.spherical (r, θ)") - else: - accessors.append("mesh.X.spherical (r, θ, φ)") - if coord_sys._geo_accessor is not None: - accessors.append("mesh.X.geo (lon, lat, depth)") - - uw.pprint(f"Coordinate access:\n") - for acc in accessors: - uw.pprint(f" • {acc}\n") - - # Only if notebook and serial + from underworld3.utilities.describe import view as _view + if uw.mpi.rank == 0: + _view(self, format=format) if uw.is_notebook and uw.mpi.size == 1: uw.visualisation.plot_mesh(self, window_size=(600, 400)) - - # Total number of cells - nstart, nend = self.dm.getHeightStratum(0) - num_cells = nend - nstart - - uw.pprint(f"Number of cells: {num_cells}\n") - - # Cell-quality summary (the conditioning-relevant tail; - # full metrics + per-cell arrays via mesh.quality()). - try: - Q = self.quality() - if Q.get("element") == "2D-simplex": - uw.pprint( - f"Cell quality: q_min={Q['q_min']:.3f} " - f"mean={Q['q_mean']:.2f} | poor(q<0.3): " - f"{Q['n_q_lt_0p3']} | worst aspect " - f"{Q['aspect_max']:.1f} | max size-jump " - f"{Q['sizejump_max']:.1f}\n") - if Q["n_q_lt_0p2"] > 0: - uw.pprint( - f" ! {Q['n_q_lt_0p2']} cell(s) " - f"q<0.2 (near-degenerate — solver " - f"conditioning hazard)\n") - else: - uw.pprint( - f"Cell quality: vol_min/mean=" - f"{Q['vol_min_over_mean']:.3f} " - f"(2-D triangle mesh needed for shape " - f"metrics)\n") - uw.pprint(" (full metrics: mesh.quality())\n") - except Exception: - pass - - self._print_variable_table() - - ## Boundary information — sizes are omitted at level 0, so no - ## collective gathers are needed (they were dead results here). - self._print_boundary_table(with_sizes=False) - - uw.pprint(f"Use view(1) to view detailed mesh information.\n") - elif level == 1: if uw.mpi.rank == 0: print(f"\n") diff --git a/src/underworld3/discretisation/discretisation_mesh_variables.py b/src/underworld3/discretisation/discretisation_mesh_variables.py index a2622df28..9d32dee20 100644 --- a/src/underworld3/discretisation/discretisation_mesh_variables.py +++ b/src/underworld3/discretisation/discretisation_mesh_variables.py @@ -545,34 +545,26 @@ def remesh_policy(self, value): else: self._remesh_policy = RemeshPolicy(value) - def _object_viewer(self): - """This will substitute specific information about this object""" - from IPython.display import Latex, Markdown, display - from textwrap import dedent - - # feedback on this instance - - display( - Markdown(f"**MeshVariable:**"), - Markdown( - f"""\ - > symbol: ${self.symbol}$\n - > shape: ${self.shape}$\n - > degree: ${self.degree}$\n - > continuous: `{self.continuous}`\n - > type: `{self.vtype.name}`""" - ), - Markdown(f"**FE Data:**"), - Markdown( - f""" - > PETSc field id: ${self.field_id}$ \n - > PETSc field name: `{self.clean_name}` """ - ), - ) - - display(self.array), - - return + def describe(self, depth=4): + """What this variable is, as data: its symbol, shape, degree, + continuity, type and units, and the mesh it lives on.""" + from underworld3.utilities.describe import record + import sympy + facts = { + "symbol": str(getattr(self, "symbol", "")), + "components": int(getattr(self, "num_components", 1)), + "shape": str(getattr(self, "shape", "")), + "degree": int(getattr(self, "degree", 0)), + "continuous": bool(getattr(self, "continuous", True)), + "type": getattr(getattr(self, "vtype", None), "name", None), + "mesh": getattr(getattr(self, "mesh", None), "name", None), + } + units = getattr(self, "units", None) + if units: + facts["units"] = str(units) + summary = (f"{facts['type'] or 'field'}, {facts['components']} component(s), " + f"P{facts['degree']}{'' if facts['continuous'] else ' discontinuous'}") + return record("variable", getattr(self, "name", None), summary, facts=facts) def clone(self, name, varsymbol): """Create a copy of this variable with new name and symbol. diff --git a/src/underworld3/discretisation/enhanced_variables.py b/src/underworld3/discretisation/enhanced_variables.py index 9a1388fa3..7b63211ad 100644 --- a/src/underworld3/discretisation/enhanced_variables.py +++ b/src/underworld3/discretisation/enhanced_variables.py @@ -245,6 +245,25 @@ def _setup_persistence_features(self): # === CRITICAL: Direct property access for assignment operations === + def describe(self, depth=4): + """What this variable is, as data: the base variable's description + with the units and persistence this wrapper adds.""" + d = self._base_var.describe(depth=depth) + facts = d.setdefault("facts", {}) + try: + facts["units"] = str(self.units) if self.has_units else "none (dimensionless)" + if self.has_units: + facts["dimensionality"] = str(self.dimensionality) + except Exception: + pass + facts["persistent"] = bool(getattr(self, "_persistent", False)) + return d + + def view(self, format=None, depth=None): + """Show what this variable is; see :func:`underworld3.utilities.describe.view`.""" + from underworld3.utilities.describe import view as _view + _view(self, format=format, depth=depth) + @property def data(self): """Direct access to data array (supports += and other assignment ops).""" @@ -744,45 +763,6 @@ def __str__(self): """String representation.""" return self.__repr__() - def view(self): - """ - Display detailed information about the enhanced variable including units. - - Shows variable name, dimensions, shape, units information, and sample data. - """ - print(f"Enhanced MeshVariable: {self.name}") - print(f" Components: {self.num_components}") - print(f" Degree: {self.degree}") - print(f" Array shape: {self.array.shape}") - - # Units information - if self.has_units: - print(f" Units: {self.units}") - print(f" Dimensionality: {self.dimensionality}") - print(f" Units backend: pint") - else: - print(f" Units: None (dimensionless)") - - # Persistence information - if self._persistent: - print(f" Persistence: Enabled") - else: - print(f" Persistence: Disabled") - - # Sample data (first few elements) - try: - if len(self.array.shape) > 0 and self.array.shape[0] > 0: - print(f" Data sample: {self.array[:3]}") - else: - print(f" Data sample: No data") - except: - print(f" Data sample: Unable to display") - - # Mathematical capabilities - if hasattr(self, "sym"): - print(f" Symbolic form: {self.sym}") - - return self # Allow chaining def create_enhanced_mesh_variable( diff --git a/src/underworld3/model.py b/src/underworld3/model.py index 8d0845db1..c3e51d05c 100644 --- a/src/underworld3/model.py +++ b/src/underworld3/model.py @@ -1608,7 +1608,10 @@ def _describe_part(self, owner, part: str, label: str) -> None: "at_step": self._open_step.index, "fingerprint": fingerprint, } - record.update(described) + # the description's own kind and its contained objects stay out of + # the record: a part record IS a kind, and the children are recorded + # as parts of their own when they act + record.update({k: v for k, v in described.items() if k not in ("kind", "children")}) self._parts[part] = record self._write_transcript_line(record) @@ -5596,179 +5599,61 @@ def set_petsc_option(self, option: str, value: str): except ImportError: print("Warning: petsc4py not available, cannot set PETSc option") - def view(self, verbose: int = 0, show_materials: bool = True, show_petsc: bool = False): - """ - Display a concise summary of the model contents. - - Parameters - ---------- - verbose : int, default 0 - Verbosity level: - 0 = Basic summary - 1 = Include variable details and material properties - 2 = Include solver information and metadata - show_materials : bool, default True - Whether to show materials summary - show_petsc : bool, default False - Whether to show PETSc options (can be lengthy) - - Example - ------- - >>> model.view() # Basic summary - >>> model.view(verbose=1) # Detailed view - >>> model.view(verbose=2, show_petsc=True) # Full details - """ - import textwrap - - # Build markdown content - lines = [] - lines.append(f"# Model: {self.name}") - lines.append(f"**Status:** {self.state.value} (version {self.version})") - lines.append("") - - # Mesh information - if self.mesh: - mesh_type = type(self.mesh).__name__ - try: - mesh_desc = f"{mesh_type}" - if hasattr(self.mesh, "dm") and self.mesh.dm: - # Try to get mesh statistics - try: - coords = self.mesh.dm.getCoordinates() - if coords: - node_count = coords.getSize() - mesh_desc += f" ({node_count:,} nodes)" - except: - pass - lines.append(f"**Mesh:** {mesh_desc}") - except: - lines.append(f"**Mesh:** {mesh_type}") - else: - lines.append("**Mesh:** *No mesh assigned*") - lines.append("") - - # Variables summary - var_count = len(self._variables) - lines.append(f"**Variables:** {var_count} registered") - if var_count > 0 and verbose >= 1: - for name, var in self._variables.items(): - try: - var_type = type(var).__name__ - if hasattr(var, "num_components"): - components = var.num_components - if components == 1: - var_desc = f"scalar" - elif components in [2, 3]: - var_desc = f"vector ({components}D)" - else: - var_desc = f"tensor ({components} components)" - else: - var_desc = "unknown type" - lines.append(f" - `{name}`: {var_desc}") - except: - lines.append(f" - `{name}`: {type(var).__name__}") - elif var_count > 0: - var_names = list(self._variables.keys()) - if len(var_names) <= 3: - lines.append(f" - {', '.join(f'`{name}`' for name in var_names)}") - else: - lines.append(f" - {', '.join(f'`{name}`' for name in var_names[:3])}, ...") - lines.append("") - - # Swarms summary - swarm_count = len(self._swarms) - lines.append(f"**Swarms:** {swarm_count} registered") - if swarm_count > 0 and verbose >= 1: - for swarm_id, swarm in list(self._swarms.items()): - try: - particle_count = swarm.local_size - lines.append(f" - Swarm {swarm_id}: {particle_count:,} particles") - except Exception: - # Summary display only: a partially built swarm (no DM - # yet) should not break the model overview. - lines.append(f" - Swarm {swarm_id}: {type(swarm).__name__}") - lines.append("") - - # Materials summary - if show_materials and self.materials: - mat_count = len(self.materials) - lines.append(f"**Materials:** {mat_count} defined") - if verbose >= 1: - for mat_name, properties in self.materials.items(): - prop_count = len(properties) - if prop_count <= 3: - prop_names = list(properties.keys()) - lines.append(f" - `{mat_name}`: {', '.join(prop_names)}") - else: - prop_names = list(properties.keys())[:3] - lines.append( - f" - `{mat_name}`: {', '.join(prop_names)}, ... ({prop_count} total)" - ) - else: - mat_names = list(self.materials.keys()) - if len(mat_names) <= 3: - lines.append(f" - {', '.join(f'`{name}`' for name in mat_names)}") - else: - lines.append(f" - {', '.join(f'`{name}`' for name in mat_names[:3])}, ...") - lines.append("") - - # Solvers summary - if verbose >= 2: - solver_count = len(self._solvers) - lines.append(f"**Solvers:** {solver_count} registered") - if solver_count > 0: - for name, solver in self._solvers.items(): - lines.append(f" - `{name}`: {type(solver).__name__}") - lines.append("") - - # PETSc options - if show_petsc and self.petsc_state: - lines.append(f"**PETSc Options:** {len(self.petsc_state)} set") - if verbose >= 1: - for option, value in self.petsc_state.items(): - lines.append(f" - `{option}`: {value}") - lines.append("") - - # Metadata - if verbose >= 2 and self.metadata: - lines.append(f"**Metadata:** {len(self.metadata)} entries") - for key, value in self.metadata.items(): - if isinstance(value, dict): - lines.append(f" - `{key}`: dict with {len(value)} items") - elif isinstance(value, (list, tuple)): - lines.append(f" - `{key}`: {type(value).__name__} with {len(value)} items") - else: - value_str = str(value) - if len(value_str) > 50: - value_str = value_str[:47] + "..." - lines.append(f" - `{key}`: {value_str}") - lines.append("") - - # Usage hints - lines.append("---") - lines.append("**Usage hints:**") - lines.append("- `model.view(verbose=1)` - Show variable and material details") - lines.append("- `model.view(verbose=2)` - Show all components including solvers") - lines.append("- `model.to_dict()` - Export complete configuration") - lines.append("- `model.to_yaml()` - Export as YAML file") - if self._variables: - lines.append("- `model.get_variable('name')` - Access specific variables") - if self.materials: - lines.append("- `model.get_material('name')` - Access material properties") - - # Display as markdown - content = "\n".join(lines) + def describe(self, depth=2): + """What this model holds, as data: its name, its scales as they were + declared, its clock, and the meshes, swarms and solvers it + orchestrates as children, each describing itself one level down.""" + from underworld3.utilities.describe import record + facts = {} try: - from IPython.display import Markdown, display + reference = self.get_reference_quantities() or {} + if reference: + facts["scales"] = {k: f"{v['magnitude']:.4g} {v['units']}" if isinstance(v, dict) else str(v) + for k, v in reference.items()} + except Exception: + pass + try: + facts["time"] = str(self.tracker.time) + facts["step"] = int(self.tracker.step) + except Exception: + pass + facts["meshes"] = len(self._meshes) + facts["variables"] = len(self._variables) + facts["swarms"] = len(self._swarms) + facts["solvers"] = len(self._solvers) + sum( + 1 for obj in getattr(self, "_part_objects", {}).values() + if not any(obj is s for s in self._solvers.values())) + if self.materials: + facts["materials"] = list(self.materials.keys()) + children = [] + if depth > 0: + held = list(self._meshes.values()) + list(self._swarms.values()) + list(self._solvers.values()) + for obj in getattr(self, "_part_objects", {}).values(): + if not any(obj is h for h in held): + held.append(obj) + for obj in held: + if hasattr(obj, "describe"): + try: + children.append(obj.describe(depth=depth - 1)) + except Exception: + continue + summary = (f"{facts['meshes']} mesh(es), {facts['variables']} variable(s), " + f"{facts['swarms']} swarm(s), {facts['solvers']} solver(s)") + return record("model", getattr(self, "name", None), summary, facts=facts, children=children) + + def view(self, verbose: int = 0, show_materials: bool = True, show_petsc: bool = False, + format=None): + """Show what the model holds: :meth:`describe` rendered for a + notebook or a terminal, or in the ``format`` named. ``verbose`` + adds a level of contained objects per unit.""" + from underworld3.utilities.describe import view as _view + _view(self, format=format, depth=1 + int(verbose)) + if show_petsc: + try: + self.mesh.dm.view() + except Exception: + pass - display(Markdown(content)) - except (ImportError, NameError): - # Fallback to plain text if not in Jupyter - print("=" * 60) - # Convert markdown to plain text - plain_text = content.replace("# ", "").replace("**", "").replace("`", "'") - print(plain_text) - print("=" * 60) def __repr__(self): """Override Pydantic's __repr__ for better user experience.""" @@ -5791,152 +5676,6 @@ def __str__(self): """String representation for print() calls.""" return self.__repr__() - def view(self): - """ - Display comprehensive model information following the established view() pattern. - - Shows model configuration, units setup, registered components, and provides - guidance for setting up units if not configured. - """ - try: - from IPython.display import Markdown, display - - # Build markdown content - content = [f"## Model: {self.name}"] - - # Model state and basic info - content.append(f"**State**: {self.state.value}") - content.append(f"**Version**: {self.version}") - - # Mesh information - if self.mesh: - content.append(f"\n### Primary Mesh") - content.append(f"- **Type**: {type(self.mesh).__name__}") - content.append( - f"- **Dimension**: {self.mesh.dim if hasattr(self.mesh, 'dim') else 'Unknown'}" - ) - - total_meshes = len(self._meshes) - if total_meshes > 1: - content.append(f"- **Total meshes**: {total_meshes}") - elif total_meshes == 0: - content.append(f"\n### Meshes") - content.append("⚠️ No meshes registered") - - # Variables and swarms - var_count = len(self._variables) - swarm_count = len(self._swarms) - - content.append(f"\n### Components") - content.append(f"- **Variables**: {var_count}") - content.append(f"- **Swarms**: {swarm_count}") - content.append(f"- **Solvers**: {len(self._solvers)}") - - # Units information - ref_qty = self.get_reference_quantities() - content.append(f"\n### Units Configuration") - - if ref_qty: - content.append(f"✅ **Reference quantities set** ({len(ref_qty)} quantities):") - for name, info in ref_qty.items(): - content.append(f"- **{name}**: `{info['value']}`") - - # Show derived fundamental scalings - scalings = self.derive_fundamental_scalings() - if scalings: - content.append(f"\n**Derived Fundamental Scalings:**") - derivation_info = self.metadata.get("derived_scalings", {}).get( - "derivation_info", {} - ) - for dim in ["[length]", "[time]", "[mass]", "[temperature]"]: - if dim in scalings: - value = scalings[dim] - source = derivation_info.get(dim, "direct") - content.append(f"- **{dim.strip('[]').title()}**: `{value}` _{source}_") - - content.append( - "\n💡 *Use `model.show_optimal_units()` to see recommended units for your problem*" - ) - else: - content.append("⚠️ **No reference quantities set**") - content.append("\nTo set up dimensional analysis:") - content.append("```python") - content.append("model.set_reference_quantities(") - content.append(" mantle_temperature=1500*uw.units.K,") - content.append(" mantle_viscosity=1e21*uw.units.Pa*uw.units.s,") - content.append(" plate_velocity=5*uw.units.cm/uw.units.year") - content.append(")") - content.append("```") - - # Materials information - if self.materials: - content.append(f"\n### Materials ({len(self.materials)})") - for mat_name, properties in self.materials.items(): - content.append(f"- **{mat_name}**: {len(properties)} properties") - - # Additional metadata - if self.metadata: - non_ref_metadata = { - k: v for k, v in self.metadata.items() if k != "reference_quantities" - } - if non_ref_metadata: - content.append(f"\n### Metadata") - content.append(f"- **Entries**: {len(non_ref_metadata)}") - - display(Markdown("\n".join(content))) - - except ImportError: - # Fallback for non-Jupyter environments using uw.pprint - import underworld3 as uw - - uw.pprint(f"Model: {self.name}") - uw.pprint("=" * 40) - uw.pprint(f"State: {self.state.value}") - uw.pprint(f"Version: {self.version}") - - # Mesh info - if self.mesh: - uw.pprint(f"\nPrimary Mesh: {type(self.mesh).__name__}") - if hasattr(self.mesh, "dim"): - uw.pprint(f" Dimension: {self.mesh.dim}") - - # Components - uw.pprint(f"\nComponents:") - uw.pprint(f" Variables: {len(self._variables)}") - uw.pprint(f" Swarms: {len(self._swarms)}") - uw.pprint(f" Solvers: {len(self._solvers)}") - - # Units - ref_qty = self.get_reference_quantities() - uw.pprint(f"\nUnits Configuration:") - if ref_qty: - uw.pprint(f" Reference quantities: {len(ref_qty)} set") - for name, info in ref_qty.items(): - uw.pprint(f" {name}: {info['value']}") - - # Show derived fundamental scalings - scalings = self.derive_fundamental_scalings() - if scalings: - uw.pprint(f"\n Derived Fundamental Scalings:") - derivation_info = self.metadata.get("derived_scalings", {}).get( - "derivation_info", {} - ) - for dim in ["[length]", "[time]", "[mass]", "[temperature]"]: - if dim in scalings: - value = scalings[dim] - source = derivation_info.get(dim, "direct") - uw.pprint(f" {dim.strip('[]').title()}: {value} ({source})") - - uw.pprint(f"\n Use model.show_optimal_units() for detailed recommendations") - else: - uw.pprint(" No reference quantities set") - uw.pprint(" To set up: model.set_reference_quantities(...)") - - # Materials - if self.materials: - uw.pprint(f"\nMaterials: {len(self.materials)}") - for mat_name, properties in self.materials.items(): - uw.pprint(f" {mat_name}: {len(properties)} properties") # Global default model for automatic registration diff --git a/src/underworld3/swarm.py b/src/underworld3/swarm.py index 05190e15a..f15703e9e 100644 --- a/src/underworld3/swarm.py +++ b/src/underworld3/swarm.py @@ -1842,28 +1842,22 @@ def unpack_raw_data_from_petsc(self, squeeze=True, sync=None): else: return result - def _object_viewer(self): - """This will substitute specific information about this object""" - from IPython.display import Latex, Markdown, display - from textwrap import dedent - - # feedback on this instance - # - display( - Markdown( - f"""**SwarmVariable:** - > symbol: ${self.symbol}$\n - > shape: ${self.shape}$\n - > proxy: ${self._proxy}$\n - > proxy_location: `{self._proxy_location}`\n - > proxy_degree: ${self._proxy_degree}$\n - > proxy_continuous: `{self._proxy_continuous}`\n - > type: `{self.vtype.name}`""" - ), - ) - - display(self.data), - return + def describe(self, depth=4): + """What this swarm variable is, as data: its symbol, shape, type and + how it is proxied onto the mesh.""" + from underworld3.utilities.describe import record + facts = { + "symbol": str(getattr(self, "symbol", "")), + "shape": str(getattr(self, "shape", "")), + "type": getattr(getattr(self, "vtype", None), "name", None), + "proxy": bool(getattr(self, "_proxy", False)), + "proxy location": str(getattr(self, "_proxy_location", "")), + "proxy degree": getattr(self, "_proxy_degree", None), + "proxy continuous": bool(getattr(self, "_proxy_continuous", True)), + } + return record("swarm_variable", getattr(self, "name", None), + f"{facts['type'] or 'particle field'}, proxied at {facts['proxy location'] or 'nodes'}", + facts=facts) def _resolve_stencil(self, nnn, order, n_particles): """Stencil size and reproduction order this rank can actually support. @@ -3316,6 +3310,31 @@ class Swarm(Stateful, uw_object): instances = 0 @timing.routine_timer_decorator + def describe(self, depth=4): + """What this swarm is, as data: its particle count and its mesh, with + its variables as children.""" + from underworld3.utilities.describe import record + facts = {"mesh": getattr(getattr(self, "mesh", None), "name", None)} + try: + facts["particles on this rank"] = int(self.local_size) + except Exception: + pass + children = [] + if depth > 0: + try: + variables = list(self.vars.values()) + except Exception: + variables = [] + for var in variables: + if hasattr(var, "describe"): + try: + children.append(var.describe(depth=depth - 1)) + except Exception: + continue + summary = "particle swarm" + (f", {facts['particles on this rank']} particles on this rank" + if "particles on this rank" in facts else "") + return record("swarm", getattr(self, "name", None), summary, facts=facts, children=children) + def __init__(self, mesh, recycle_rate=0, verbose=False, clip_to_mesh=True): # Particle recycling (streak swarms) was excised in 2026-07: the # machinery had been broken (NameError) and untested for some time diff --git a/src/underworld3/systems/advection_diffusion_eulerian.py b/src/underworld3/systems/advection_diffusion_eulerian.py index 291eb9e5d..8f7424fe4 100644 --- a/src/underworld3/systems/advection_diffusion_eulerian.py +++ b/src/underworld3/systems/advection_diffusion_eulerian.py @@ -411,18 +411,6 @@ def preconditioner(self, value): SNES_Scalar.preconditioner.fset(self, value) self._set_linear_solver(multigrid=self._preconditioner != "auto") - def _object_viewer(self): - from IPython.display import Latex, display - - super()._object_viewer() - scheme = {("am", 1): f"Adams-Moulton order 1, theta = {self._theta}", - ("bdf", 1): "backward Euler"}.get( - (self.integrator, self.order), - f"{self.integrator.upper()} order {self.order}") - display(Latex(r"$\quad\mathrm{u} = $ " + self.u.sym._repr_latex_())) - display(Latex(r"$\quad\mathbf{v} = $ " + self.V_fn._repr_latex_())) - display(Latex(r"$\quad\Delta t = $ " + self.delta_t._repr_latex_())) - display(Latex(rf"$\quad$ time scheme: {scheme}")) # ------------------------------------------------------------------ # Scheme description diff --git a/src/underworld3/systems/ddt.py b/src/underworld3/systems/ddt.py index 92178213a..2c63c33a7 100644 --- a/src/underworld3/systems/ddt.py +++ b/src/underworld3/systems/ddt.py @@ -557,6 +557,33 @@ class _DDtBase(uw_object): Symbolic, Eulerian, SemiLagrangian). """ + def describe(self, depth=4): + """What this history is, as data: the scheme (the class), its order, + its weighting, the field it tracks and the history slots it keeps. + The method metadata a run record needs to say which time integrator + a part used.""" + import sympy + from underworld3.utilities.describe import record, term + + facts = {"scheme": type(self).__name__, "order": getattr(self, "order", None)} + theta = getattr(self, "theta", None) + if theta is not None: + facts["theta"] = theta + terms = [] + psi = getattr(self, "_psi_fn", None) + if psi is not None: + terms.append(term(getattr(self, "_psi_fn_symbol", "psi"), psi, + description="the quantity tracked", + symbol=getattr(self, "_psi_fn_symbol", None))) + slots = getattr(self, "psi_star", None) or [] + if slots: + terms.append({"name": "history", "symbol": getattr(self, "_psi_star_symbol", None), + "latex": ", ".join(sympy.latex(s) for s in slots), + "text": ", ".join(str(s) for s in slots), "units": None, + "description": f"{len(slots)} history slot(s)", "where": []}) + doc = (type(self).__doc__ or "").strip().split("\n")[0] + return record("history", type(self).__name__, doc, facts=facts, terms=terms) + def _init_history_tracking(self, order): """Deferred-initialisation and variable-dt bookkeeping attributes.""" # The timestep as a runtime constant of the compiled kernels: every @@ -1165,15 +1192,7 @@ def psi_fn(self, new_fn): self._shape = new_fn.shape return - def _object_viewer(self): - # Local import: IPython is an optional, notebook-only dependency. - from IPython.display import Latex, display - # Display the primary variable - display(Latex(rf"$\quad {self._psi_fn_symbol} = {sympy.latex(self._psi_fn)}$")) - # Display the history variable using the different symbol. - history_latex = ", ".join([sympy.latex(elem) for elem in self.psi_star]) - display(Latex(rf"$\quad {self._psi_star_symbol} = \left[{history_latex}\right]$")) def update_history_fn(self): r"""Copy current :math:`\psi` to the first history slot ``psi_star[0]``.""" @@ -1440,14 +1459,6 @@ def psi_fn(self, new_fn): # self._psi_star_projection_solver.uw_function = self.psi_fn return - def _object_viewer(self): - # Local import: IPython is an optional, notebook-only dependency. - from IPython.display import Latex, Markdown, display - - super()._object_viewer() - - ## feedback on this instance - display(Latex(rf"$\quad$History steps = {self.order}")) def _setup_projections(self): """Initialize projection solvers for history updates.""" @@ -1941,12 +1952,6 @@ def stabilisation_flux(self, R): column = R.reshape(len(R), 1) return self.tau() * (column * self.advecting_velocity(0)) - def _object_viewer(self): - from IPython.display import Latex, display - - super()._object_viewer() - display(Latex(r"$\quad\mathbf{a} = $ " + self.V_fn._repr_latex_())) - display(Latex(rf"$\quad$ integrator: {self.integrator}, tau shape: {self.tau_shape}")) class CharacteristicTrace: @@ -2856,13 +2861,6 @@ def enable_source_snapshot(self): # currently-installed projection source. self.psi_fn = self._psi_fn - def _object_viewer(self): - # Local import: IPython is an optional, notebook-only dependency. - from IPython.display import Latex, Markdown, display - - super()._object_viewer() - - display(Latex(rf"$\quad$History steps = {self.order}")) def initialise_history(self): r"""Initialize all history slots to the current value of :math:`\psi`. @@ -3861,17 +3859,6 @@ def state(self, s: "DDtLagrangianState") -> None: # No theta parameter on this flavor — fixed Crank-Nicolson value. self._restore_core_state(s, am_theta=0.5) - def _object_viewer(self): - # Local import: IPython is an optional, notebook-only dependency. - from IPython.display import Latex, Markdown, display - - super()._object_viewer() - - ## feedback on this instance - # Note: dt_physical is not tracked on the Lagrangian DDt classes, - # so the viewer reports the expression and history depth only. - display(Latex(r"$\quad\psi = $ " + sympy.sympify(self.psi_fn)._repr_latex_())) - display(Latex(rf"$\quad$History steps = {self.order}")) def initialise_history(self): r"""Initialize all history slots to the current value of :math:`\psi`. @@ -4203,17 +4190,6 @@ def state(self, s: "DDtLagrangianSwarmState") -> None: # No theta parameter on this flavor — fixed Crank-Nicolson value. self._restore_core_state(s, am_theta=0.5) - def _object_viewer(self): - # Local import: IPython is an optional, notebook-only dependency. - from IPython.display import Latex, Markdown, display - - super()._object_viewer() - - ## feedback on this instance - # Note: dt_physical is not tracked on the Lagrangian DDt classes, - # so the viewer reports the expression and history depth only. - display(Latex(r"$\quad\psi = $ " + sympy.sympify(self.psi_fn)._repr_latex_())) - display(Latex(rf"$\quad$History steps = {self.order}")) def initialise_history(self): r"""Initialize all history slots to the current value of :math:`\psi`. @@ -4693,12 +4669,6 @@ def _check_psi_shape(self, psi_fn): stacklevel=3, ) - def _object_viewer(self): - from IPython.display import Latex, Markdown, display - super()._object_viewer() - display(Latex(r"$\quad\psi = $ " + self.psi_fn._repr_latex_())) - display(Latex(r"$\quad\mathbf{v} = $ " + sympy.Matrix(self.V_fn)._repr_latex_())) - display(Latex(rf"$\quad$History steps = {self.order} (at the integration points)")) # ------------------------------------------------------------------ def _nudged_node_coords(self, var): diff --git a/src/underworld3/systems/solvers.py b/src/underworld3/systems/solvers.py index e062684e9..b3aafb3e9 100644 --- a/src/underworld3/systems/solvers.py +++ b/src/underworld3/systems/solvers.py @@ -4209,15 +4209,6 @@ class SNES_AdvectionDiffusion(SNES_Scalar): ("V_fn", "advecting velocity"), ) - def _object_viewer(self): - from IPython.display import Latex, Markdown, display - - super()._object_viewer() - - ## feedback on this instance - display(Latex(r"$\quad\mathrm{u} = $ " + self.u.sym._repr_latex_())) - display(Latex(r"$\quad\mathbf{v} = $ " + self._V_fn._repr_latex_())) - display(Latex(r"$\quad\Delta t = $ " + self.delta_t._repr_latex_())) @timing.routine_timer_decorator def __init__( @@ -4665,14 +4656,6 @@ class SNES_Diffusion(SNES_Scalar): ("f", "volumetric source term"), ) - def _object_viewer(self): - from IPython.display import Latex, Markdown, display - - super()._object_viewer() - - ## feedback on this instance - display(Latex(r"$\quad\mathrm{u} = $ " + self.u.sym._repr_latex_())) - display(Latex(r"$\quad\Delta t = $ " + self.delta_t._repr_latex_())) @timing.routine_timer_decorator def __init__( @@ -5006,16 +4989,6 @@ class SNES_NavierStokes(SNES_Stokes_SaddlePt): ("penalty", "augmented-Lagrangian grad-div penalty (0 = off)"), ) - def _object_viewer(self): - from IPython.display import Latex, Markdown, display - - super()._object_viewer() - - ## feedback on this instance - display(Latex(r"$\quad\mathrm{u} = $ " + self.u.sym._repr_latex_())) - display(Latex(r"$\quad\mathbf{p} = $ " + self.p.sym._repr_latex_())) - display(Latex(r"$\quad\Delta t = $ " + self.delta_t._repr_latex_())) - display(Latex(rf"$\quad\rho = $" + self.rho._repr_latex_())) @timing.routine_timer_decorator def __init__( diff --git a/src/underworld3/utilities/_api_tools.py b/src/underworld3/utilities/_api_tools.py index e6673cfdb..190579b07 100644 --- a/src/underworld3/utilities/_api_tools.py +++ b/src/underworld3/utilities/_api_tools.py @@ -542,70 +542,70 @@ def _reset(): @class_or_instance_method def _ipython_display_(self_or_cls): - from IPython.display import Latex, Markdown, display - from textwrap import dedent import inspect from .docstring_utils import render_docstring, in_jupyter - - ## Docstring (static / class documentation) - if inspect.isclass(self_or_cls): - docstring = self_or_cls.__doc__ - rendered = render_docstring(docstring, target="auto") + rendered = render_docstring(self_or_cls.__doc__, target="auto") if in_jupyter(): + from IPython.display import Markdown, display display(Markdown(rendered)) else: print(rendered) - - else: - if in_jupyter(): - display( - Markdown( - f"**Class**: {self_or_cls.__class__}", - ) - ) - else: - print(f"Class: {self_or_cls.__class__}") - self_or_cls._object_viewer() - - return + return + self_or_cls.view() # View is similar but we can give it arguments to force the # class documentation for an instance. @class_or_instance_method - def view(self_or_cls, class_documentation=False): - from IPython.display import Latex, Markdown, display + def view(self_or_cls, format=None, depth=None, class_documentation=False): + """Show what this object is. + + An object that describes itself (:meth:`describe`) is rendered from + that description: Markdown with mathematics in a notebook, plain + text in a terminal, or the ``format`` named — ``"markdown"``, + ``"text"``, ``"latex"``, ``"yaml"`` or ``"json"``. ``depth`` limits + how many levels of contained objects are shown. On a class, or with + ``class_documentation=True``, the class documentation is shown too. + """ import inspect from .docstring_utils import render_docstring, in_jupyter - - ## Docstring (static / class documentation) - - if inspect.isclass(self_or_cls) or class_documentation == True: - docstring = self_or_cls.__doc__ - rendered = render_docstring(docstring, target="auto") + if inspect.isclass(self_or_cls) or class_documentation: + rendered = render_docstring(self_or_cls.__doc__, target="auto") if in_jupyter(): + from IPython.display import Markdown, display display(Markdown(rendered)) + if class_documentation: + display(Markdown("---")) else: print(rendered) - - if class_documentation: - if in_jupyter(): - display(Markdown("---")) - else: + if class_documentation: print("---") - - if not inspect.isclass(self_or_cls): - if in_jupyter(): - display( - Markdown( - f"**Class**: {self_or_cls.__class__}", - ), - ) - else: - print(f"Class: {self_or_cls.__class__}") - - self_or_cls._object_viewer() + if inspect.isclass(self_or_cls): + return + if type(self_or_cls).describe is not uw_object.describe: + from .describe import view as _view + _view(self_or_cls, format=format, depth=depth) + return + # an object without a description of its own: the legacy viewer + if in_jupyter(): + from IPython.display import Markdown, display + display(Markdown(f"**Class**: {self_or_cls.__class__}")) + else: + print(f"Class: {self_or_cls.__class__}") + self_or_cls._object_viewer() + + def describe(self, depth=4): + """What this object is, as data: a record with ``kind``, ``name`` and + ``summary``, and whatever else the object holds — ``facts``, + ``terms``, ``forms``, ``conditions``, ``children`` — as + :mod:`underworld3.utilities.describe` lays it out. Rendered by + :meth:`view`; serialised by the run transcript. Subclasses override + this; the base gives the class and its first line of documentation. + """ + from .describe import record + doc = (type(self).__doc__ or "").strip().split("\n")[0] + return record(type(self).__name__.lower(), getattr(self, "name", None), doc) # placeholder def _object_viewer(self): diff --git a/src/underworld3/utilities/describe.py b/src/underworld3/utilities/describe.py new file mode 100644 index 000000000..7ccbb102d --- /dev/null +++ b/src/underworld3/utilities/describe.py @@ -0,0 +1,367 @@ +r"""Structured descriptions of Underworld objects, rendered on demand. + +An object says what it is once, as data, through ``describe()``. The +description is a plain tree: strings, numbers, lists and dicts, so it can +be written to a run transcript, returned by a query, or rendered for a +reader in whichever form the reader is using. ``render`` turns one tree +into Markdown with mathematics for a notebook, plain text for a terminal, +a LaTeX fragment for a note, or YAML and JSON for a record or a tool. +``view()`` on any object is ``render(describe())`` in the form the current +session wants. + +The record +---------- + +Every description carries:: + + kind what sort of object: "solver", "mesh", "variable", ... + name the object's name + summary one line for a reader + +and any of:: + + facts {label: value} scalar facts, in a stable order + terms [{name, symbol, latex, text, units, description, where}] + the named quantities the object holds + forms {name: {symbol, latex, text, description, where}} + its equations + conditions [{type, boundary, latex, text, ...}] + its boundary conditions + children [record, ...] the objects it contains + +``where`` is the list of named expressions inside a value, each with the +same keys as a term and its own ``where``, down to the depth the caller +asked for. A solver's description also keeps the keys the run transcript +records (``forms``, ``boundary_conditions``, ``terms``); ``conditions`` +and ``boundary_conditions`` are the same list under either name. +""" + +import json +import re + +FORMATS = ("markdown", "text", "latex", "yaml", "json") + + +def record(kind, name, summary="", **fields): + """A description with the shared keys first and the rest in the order given.""" + out = {"kind": str(kind), "name": str(name) if name is not None else None, + "summary": str(summary or "")} + for key, value in fields.items(): + if value is not None: + out[key] = value + return out + + +def term(name, value=None, description="", units=None, symbol=None, where=None): + """One named quantity as a description entry: the value as LaTeX and as + text, its units and description. ``value`` may be a SymPy expression, a + named Underworld expression, a quantity or a number.""" + import sympy + + if hasattr(value, "sym") and hasattr(value, "symbol"): + symbol = symbol or str(value.symbol) + description = description or str(getattr(value, "description", "") or "") + units = units or (str(value.units) if getattr(value, "units", None) else None) + value = value.sym + if description == "No description provided": + description = "" + if value is None: + latex = text = None + else: + try: + latex = sympy.latex(value) + except Exception: + latex = None + text = str(value) + return {"name": str(name), "symbol": symbol, "latex": latex, "text": text, + "units": units, "description": description, "where": list(where or [])} + + +def plain(value): + """``value`` with every leaf a string, number, boolean or None, so the + tree can be written as YAML or JSON. Sequences and mappings are kept.""" + if value is None or isinstance(value, (bool, int, float, str)): + return value + if isinstance(value, dict): + return {str(k): plain(v) for k, v in value.items()} + if isinstance(value, (list, tuple, set)): + return [plain(v) for v in value] + try: + import numpy as np + if isinstance(value, np.generic): + return value.item() + if isinstance(value, np.ndarray): + return value.tolist() + except ImportError: + pass + return str(value) + + +# --- rendering ----------------------------------------------------------- + +def render(description, format="markdown", depth=None): + """One description as a string in ``format``. + + ``depth`` limits how many levels of children are rendered; ``None`` + renders them all. The ``where`` lists inside terms and forms are + rendered as far as the description carries them. + """ + if format not in FORMATS: + raise ValueError(f"format must be one of {FORMATS}, not {format!r}") + if format == "json": + return json.dumps(plain(_pruned(description, depth)), indent=2) + if format == "yaml": + import yaml + return yaml.safe_dump(plain(_pruned(description, depth)), sort_keys=False, + allow_unicode=True, width=100) + lines = _render_lines(description, format, level=0, depth=depth) + return "\n".join(lines).rstrip() + "\n" + + +def _pruned(description, depth, level=0): + if depth is None or not isinstance(description, dict): + return description + out = dict(description) + if level >= depth: + out.pop("children", None) + else: + out["children"] = [_pruned(c, depth, level + 1) for c in description.get("children", [])] + return out + + +def _render_lines(d, mode, level, depth): + out = [] + title = _title(d) + if mode == "markdown": + out.append(f"{'#' * min(level + 2, 6)} {title}") + elif mode == "latex": + macro = ["section*", "subsection*", "subsubsection*", "paragraph", "subparagraph"][min(level, 4)] + out.append(f"\\{macro}{{{_tex_text(title)}}}") + else: + out.append((" " * level) + title) + out.append((" " * level) + "-" * len(title)) + if d.get("summary"): + out.append(_para(d["summary"], mode, level)) + facts = d.get("facts") or {} + if facts: + out.append("") + for label, value in facts.items(): + out.append(_bullet(f"{label}: {_fact_text(value, mode)}", mode, level)) + forms = d.get("forms") or {} + if forms: + out.append("") + if d.get("kind") == "solver": + out.append(_para(_residual_statement(mode), mode, level)) + for name, form in forms.items(): + out.extend(_form_lines(name, form, mode, level)) + conditions = d.get("conditions") + if conditions is None: + conditions = d.get("boundary_conditions") + if conditions: + out.append("") + out.append(_para(_heading_text("Boundary conditions", mode), mode, level)) + for bc in conditions: + out.append(_bullet(_condition_text(bc, mode), mode, level)) + terms = d.get("terms") + if terms: + out.append("") + out.append(_para(_heading_text("Given", mode), mode, level)) + for t in terms: + out.append(_bullet(_term_text(t, mode), mode, level)) + out.extend(_where_lines(t.get("where", []), mode, level + 1)) + elif d.get("terms_declared") is False: + out.append("") + out.append(_para(_emph("this solver does not declare the terms it was given", mode), mode, level)) + children = d.get("children") or [] + if children and (depth is None or level < depth): + for child in children: + out.append("") + out.extend(_render_lines(child, mode, level + 1, depth)) + return out + + +def _title(d): + kind = str(d.get("kind") or "object").replace("_", " ") + name = d.get("name") + return f"{kind} {name}" if name else kind + + +def _residual_statement(mode): + if mode == "text": + return "residual: int F0 phi + F1 . grad phi = 0 with" + return r"Residual $\int F_0\,\phi + F_1 \cdot \nabla\phi = 0$ with" + + +def _heading_text(text, mode): + return {"markdown": f"**{text}**", "latex": f"\\textbf{{{text}}}", "text": text}[mode] + + +def _emph(text, mode): + return {"markdown": f"*{text}*", "latex": f"\\emph{{{text}}}", "text": text}[mode] + + +def _para(text, mode, level): + return (" " * level + text) if mode == "text" else text + + +def _bullet(text, mode, level): + if mode == "markdown": + return f"- {text}" + if mode == "latex": + return f"\\item {text}" + return " " * level + " " + text + + +def _fact_text(value, mode): + if isinstance(value, (list, tuple)): + return ", ".join(str(v) for v in value) + if mode == "latex": + return _tex_text(str(value)) + return str(value) + + +def _math(latex, mode, display=False): + if mode == "markdown": + return f"$${latex}$$" if display else f"${latex}$" + if mode == "latex": + return f"\\[{latex}\\]" if display else f"${latex}$" + return latex + + +def _value_text(entry, mode): + """The value of a term or a where-entry in the mode's notation, a bare + number shown compactly.""" + if mode == "text": + text = entry.get("text") + if text in (None, ""): + return None + try: + return f"{float(text):.6g}" + except (TypeError, ValueError): + return _plain_math(text) + latex = entry.get("latex") + if latex in (None, ""): + return None + try: + return f"{float(latex):.6g}" + except (TypeError, ValueError): + return latex + + +def _form_lines(name, form, mode, level): + out = [] + symbol = form.get("symbol") or name + what = form.get("description") or "" + if mode == "text": + text = form.get("text", "") + out.append(" " * level + f" {name}: {_plain_math(text)}") + if what: + out.append(" " * level + f" {what}") + else: + out.append("") + out.append(_math(f"{symbol} = {form.get('latex', '')}", mode, display=True)) + if what: + out.append(_emph(what, mode)) + where = _where_lines(form.get("where", []), mode, level + 1) + if where: + if mode != "text": + out.append("") + out.append("where") + out.extend(where) + return out + + +def _where_lines(entries, mode, level): + out = [] + for w in entries: + symbol = w.get("symbol") or w.get("name") or "?" + value = _value_text(w, mode) + units = w.get("units") + what = w.get("description") or "" + if mode == "text": + head = " " * level + f" {_plain_math(symbol)}" + if value is not None: + head += f" = {value}" + (f" {units}" if units else "") + else: + indent = " " * (level - 1) if mode == "markdown" else "" + span = symbol if value is None else f"{symbol} = {value}" + head = f"{indent}- {_math(span, mode)}" if mode == "markdown" else f"\\item {_math(span, mode)}" + if value is not None and units: + head += f" {_units_text(units, mode)}" + if what: + head += f", {what}" + out.append(head) + out.extend(_where_lines(w.get("where", []), mode, level + 1)) + return out + + +def _term_text(t, mode): + name = t.get("name") or t.get("symbol") or "?" + value = _value_text(t, mode) + units = t.get("units") + what = t.get("description") or "" + if mode == "text": + head = str(name) + if value is not None: + head += f" = {value}" + (f" {units}" if units else "") + else: + head = f"`{name}`" if mode == "markdown" else f"\\texttt{{{_tex_text(name)}}}" + span = t.get("symbol") or "" + if value is not None: + span = f"{span} = {value}" + if span: + head += f" {_math(span, mode)}" + if value is not None and units: + head += f" {_units_text(units, mode)}" + if what: + head += f", {what}" + return head + + +def _condition_text(bc, mode): + kind = bc.get("type") or bc.get("mechanism") or "?" + where = bc.get("boundary", "?") + value = _value_text(bc, mode) + line = f"{kind} on {where}" + if value: + line += f": {value}" if mode == "text" else f": {_math(value, mode)}" + if bc.get("normal") and bc["normal"] != "mesh": + line += f" (normal {bc['normal']})" + return line + + +def _units_text(units, mode): + if mode == "latex": + return f"\\,\\mathrm{{{_tex_text(str(units))}}}" + return str(units) + + +def _tex_text(text): + return re.sub(r"([#$%&_{}])", r"\\\1", str(text)) + + +def _plain_math(text): + """SymPy's text with the Greek and the sub/superscripts a terminal can + show, through the transcript's own symbol printer.""" + try: + from underworld3.utilities.transcript_report import _plain_symbol + return _plain_symbol(text) + except Exception: + return str(text) + + +# --- viewing ------------------------------------------------------------- + +def view(target, format=None, depth=None, **describe_kwargs): + """Show a description: ``target`` is an object with ``describe()`` or a + description already made. With no ``format``, Markdown with mathematics + in a notebook and plain text elsewhere; a named format prints it.""" + description = target.describe(**describe_kwargs) if hasattr(target, "describe") else target + if format is None: + from underworld3.utilities.docstring_utils import in_jupyter + if in_jupyter(): + from IPython.display import Markdown, display + display(Markdown(render(description, "markdown", depth))) + return + format = "text" + print(render(description, format, depth), end="") diff --git a/tests/test_0017_describe_and_render.py b/tests/test_0017_describe_and_render.py new file mode 100644 index 000000000..d4ffc6340 --- /dev/null +++ b/tests/test_0017_describe_and_render.py @@ -0,0 +1,126 @@ +"""Every core object describes itself as data, and one renderer shows it. + +``describe()`` returns a plain tree — kind, name, summary, and facts, terms, +forms, conditions and children as the object has them. ``uw.render`` turns +the tree into Markdown, text, LaTeX, YAML or JSON; ``view()`` is the render +in the form the session wants. The transcript serialises the same tree, so +a notebook, a note, a run record and a query cannot disagree about what an +object is. This file holds the contract: the shared keys are present on +every kind, every format renders, the serial formats round-trip, and the +solver's description keeps the keys the transcript records. +""" +import json + +import pytest +import sympy +import yaml + +import underworld3 as uw +from underworld3.utilities.describe import FORMATS, plain, render + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + +SHARED = ("kind", "name", "summary") + + +@pytest.fixture(scope="module") +def objects(): + mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), + cellSize=1 / 4, qdegree=3) + x, y = mesh.X + v = uw.discretisation.MeshVariable("v", mesh, 2, degree=2) + p = uw.discretisation.MeshVariable("p", mesh, 1, degree=1, continuous=True) + T = uw.discretisation.MeshVariable("T", mesh, 1, degree=2) + eta = uw.expression(r"\eta", 1.0, "viscosity") + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel + stokes.constitutive_model.Parameters.shear_viscosity_0 = eta * (1 + x) + stokes.bodyforce = sympy.Matrix([0, -1]) + stokes.add_essential_bc((0.0, 0.0), "Bottom") + stokes.add_essential_bc((1.0, 0.0), "Top") + adv = uw.systems.AdvDiffusion(mesh, u_Field=T, V_fn=v.sym) + adv.constitutive_model = uw.constitutive_models.DiffusionModel + adv.constitutive_model.Parameters.diffusivity = 1.0 + swarm = uw.swarm.Swarm(mesh) + material = uw.swarm.SwarmVariable("M", swarm, 1, proxy_degree=1) + swarm.populate(fill_param=2) + model = uw.get_default_model() + return {"mesh": mesh, "variable": v, "solver": stokes, "constitutive_model": stokes.constitutive_model, + "history": adv.DuDt, "swarm": swarm, "swarm_variable": material, "model": model} + + +def test_every_kind_carries_the_shared_keys(objects): + for kind, obj in objects.items(): + d = obj.describe() + for key in SHARED: + assert key in d, (kind, key) + assert d["kind"] == kind, (kind, d["kind"]) + assert isinstance(d["summary"], str) and d["summary"], kind + + +def test_every_format_renders_every_kind(objects): + for kind, obj in objects.items(): + d = obj.describe() + for fmt in FORMATS: + text = render(d, fmt) + assert isinstance(text, str) and text.strip(), (kind, fmt) + + +def test_the_serial_formats_round_trip(objects): + d = objects["solver"].describe() + assert yaml.safe_load(render(d, "yaml")) == plain(d) + assert json.loads(render(d, "json")) == plain(d) + + +def test_the_solver_description_keeps_the_transcript_keys(objects): + d = objects["solver"].describe() + for key in ("forms", "boundary_conditions", "terms", "terms_declared", "unknown", "dim"): + assert key in d, key + assert "F0" in d["forms"] and "F1" in d["forms"] + # the constitutive model is a child, one level down, and the history of + # an advection solver names its scheme + kinds = {child["kind"] for child in d["children"]} + assert "constitutive_model" in kinds + history = objects["history"].describe() + assert history["facts"]["scheme"] == type(objects["history"]).__name__ + assert history["facts"]["order"] == objects["history"].order + + +def test_markdown_carries_the_equation_and_text_carries_the_symbols(objects): + d = objects["solver"].describe() + md = render(d, "markdown") + assert d["forms"]["F0"]["latex"] in md + assert "Boundary conditions" in md and "essential on Top" in md + txt = render(d, "text") + assert "F0:" in txt and "$" not in txt.split("F0:")[1].split("\n")[0] + tex = render(d, "latex") + assert tex.startswith("\\section*{") and "\\[" in tex + + +def test_depth_prunes_children(objects): + d = objects["model"].describe(depth=2) + assert d["children"], "the model holds its meshes, swarms and solvers" + shallow = render(d, "json", depth=0) + assert "children" not in json.loads(shallow) + + +def test_view_prints_outside_a_notebook(objects, capsys): + for kind, obj in objects.items(): + obj.view() + out = capsys.readouterr().out + assert kind.replace("_", " ") in out, kind + objects["variable"].view(format="yaml") + assert yaml.safe_load(capsys.readouterr().out)["kind"] == "variable" + + +def test_the_transcript_part_record_is_still_a_part(tmp_path, objects): + # the live default model: the test harness resets it between tests, and + # a solver registers its part with whichever model is current + model = uw.get_default_model() + model.transcript_file = str(tmp_path / "run.jsonl") + stokes = objects["solver"] + with model.step(0.0, label="describe"): + stokes.solve() + records = [json.loads(l) for l in (tmp_path / "run.jsonl").read_text().splitlines()] + parts = [r for r in records if r.get("kind") == "part"] + assert parts and "children" not in parts[0] and parts[0]["forms"] diff --git a/tests/test_0857_lagrangian_ddt_advecting_history.py b/tests/test_0857_lagrangian_ddt_advecting_history.py index fc6be9fe8..1df69e274 100644 --- a/tests/test_0857_lagrangian_ddt_advecting_history.py +++ b/tests/test_0857_lagrangian_ddt_advecting_history.py @@ -237,7 +237,7 @@ def test_lagrangian_object_viewers_do_not_raise(): order=1, fill_param=2, ) - lag_ddt._object_viewer() + lag_ddt.view() swarm = uw.swarm.Swarm(mesh) lag_swarm_ddt = ddt_module.Lagrangian_Swarm( @@ -249,4 +249,4 @@ def test_lagrangian_object_viewers_do_not_raise(): order=1, ) swarm.populate(fill_param=2) - lag_swarm_ddt._object_viewer() + lag_swarm_ddt.view() From b05d5c509e3d75f125713c8d962db7d82e7e8e63 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Fri, 25 Sep 2026 18:06:39 -0700 Subject: [PATCH 2/9] uw.Transcript: one query object over a run's record, and the decisions it lacked The transcript said what a run did; asking it anything meant walking the JSON by hand, and the digest, a notebook and a test each did that their own way. uw.Transcript reads a file, a live model or read_transcript's list and answers the questions a debugging session asks: the steps that were abandoned and by what, the rewinds and why, the solves that failed or ran capped, the run collapsed to its distinct step patterns, what a part was solving at a given step, and what changed between two steps. Every answer is the record's own data, with the outcome of a solve read by the rule the figure and the table use, and describe() gives the run in the same shape as every other object. The renderers take the query object as a source. The record gains what the questions needed. A step stopped by an exception carries abandoned_by, the exception's class and message. A rewind takes a reason and detail from the caller, since the acceptance test lives in the caller's loop and the transcript cannot infer it. A part record carries the solver's run-time constants, the clock excluded, and is written again when they change: before this a parameter changed between solves left the record quoting the old value, since nothing was rebuilt. Two things found on the way. After a rewind the run numbers its steps again from where it went back to, so an index can name several attempts; the query object keys its patterns by position and exposes the attempts. And the mesh view tested uw.is_notebook as a flag when it is a function, so every serial run tried to plot. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V --- docs/developer/index.md | 1 + docs/developer/subsystems/transcript-query.md | 59 ++++ src/underworld3/__init__.py | 1 + .../cython/petsc_generic_snes_solvers.pyx | 18 +- .../discretisation/discretisation_mesh.py | 2 +- src/underworld3/model.py | 34 +- src/underworld3/utilities/transcript_query.py | 312 ++++++++++++++++++ .../utilities/transcript_report.py | 5 +- tests/test_0018_transcript_query.py | 132 ++++++++ 9 files changed, 555 insertions(+), 9 deletions(-) create mode 100644 docs/developer/subsystems/transcript-query.md create mode 100644 src/underworld3/utilities/transcript_query.py create mode 100644 tests/test_0018_transcript_query.py diff --git a/docs/developer/index.md b/docs/developer/index.md index 3449410c7..2462b1f1f 100644 --- a/docs/developer/index.md +++ b/docs/developer/index.md @@ -209,6 +209,7 @@ subsystems/containers subsystems/checkpointing-system subsystems/model-orchestration subsystems/describe-and-view +subsystems/transcript-query subsystems/jit-cache ``` diff --git a/docs/developer/subsystems/transcript-query.md b/docs/developer/subsystems/transcript-query.md new file mode 100644 index 000000000..a250257be --- /dev/null +++ b/docs/developer/subsystems/transcript-query.md @@ -0,0 +1,59 @@ +# Querying a run's transcript + +A run writes a transcript: a header with the scales, one line per step with +the operators it applied and how each went, the description of each part +when it first acts, and notes for what was not a step, a rewind above all. +`uw.read_transcript` reads it back as data. `uw.Transcript` puts one query +object over that data, so a notebook, a test, the digest and a tool ask the +same questions of the same interpretation. + +```python +t = uw.Transcript("transcripts/latest/transcript.jsonl") # a path, a live model, or read_transcript's list +t.view() # the summary, rendered for the session +t.view(format="yaml") # the same as data +``` + +## The questions + +| call | answers | +|---|---| +| `t.abandoned()` | steps that did not commit, each with `abandoned_by`: the exception's class and message | +| `t.backtracks()` | rewinds and restores, with where they happened, the step they went back to, and the `reason` and `detail` the caller gave | +| `t.failed()`, `t.capped()` | solves that diverged; solves the SNES called converged while an inner block hit its cap or its deadline | +| `t.solves(part=...)`, `t.events(kind=..., outcome=..., step=...)` | events as `(step_index, event)`, filtered | +| `t.patterns()` | the run collapsed to its distinct step patterns: same operators, outcomes, label and completion, with nothing recorded between | +| `t.step(i)`, `t.sequence(i)` | one step as recorded; its operators in order | +| `t.compare(a, b)` | operators in one step and not the other, outcomes that changed, and the interval, wall time and completion of each | +| `t.part(name, at_step=i)` | what a part was solving at step `i`: the description recorded at or before it | +| `t.changes()` | parts whose form changed during the run, and when | +| `t.between(t0, t1)` | steps starting in an interval of the run's own time | +| `t.adjoint_segments()` | the run partitioned by adjoint support | + +Every answer is plain data, the dicts the record holds. The outcome of a +solve is read by the same rule the figure and the table use, so a solve the +digest marks amber is the one `capped()` returns. + +## Recording decisions + +The transcript cannot infer why a run went back, since the acceptance test +lives in the caller's loop. Say so when rewinding: + +```python +if displacement > limit: + model.rewind(1, reason="free surface displacement over the limit", + observed=displacement, threshold=limit, action="halve dt") + dt = dt / 2 +``` + +The note then carries `reason` and `detail`, and `t.backtracks()` returns +them. A step abandoned by an exception records the exception's class and +message as `abandoned_by` without anything from the caller. + +## The same tree as everything else + +`t.describe()` is a record in the shape every object uses (see +[Descriptions and views](describe-and-view.md)): facts for the header and +the counts, the step patterns, and the parts as children. `uw.render(...)` +turns it into Markdown, text, LaTeX, YAML or JSON, and the renderers +`uw.transcript_table`, `uw.transcript_figure` and `uw.transcript_key` take +the query object as their source. diff --git a/src/underworld3/__init__.py b/src/underworld3/__init__.py index 3d2385597..689699323 100644 --- a/src/underworld3/__init__.py +++ b/src/underworld3/__init__.py @@ -223,6 +223,7 @@ def view(): create_thermal_convection_model, ) from .utilities.describe import render +from .utilities.transcript_query import Transcript from .utilities.transcript_report import ( transcript_diagram, transcript_flowchart, diff --git a/src/underworld3/cython/petsc_generic_snes_solvers.pyx b/src/underworld3/cython/petsc_generic_snes_solvers.pyx index c58133061..10841d936 100644 --- a/src/underworld3/cython/petsc_generic_snes_solvers.pyx +++ b/src/underworld3/cython/petsc_generic_snes_solvers.pyx @@ -2571,8 +2571,22 @@ class SolverBaseClass(uw_object): model = uw.get_default_model() # What it solves, not only that it solved: the residual is # SymPy, so the weak form can be written into the transcript - # exactly as implemented. - model._describe_part(self, part, label) + # exactly as implemented. The run-time constants go with it: + # a parameter changed between solves does not rebuild the + # kernel, so its new value is what tells the record the + # equation is not the one it holds. The clock is left out, + # or a time-dependent run would re-record every step. + constants = None + try: + from underworld3.utilities._jitextension import _pack_constants + clock = getattr(self.mesh, "_t", None) + packed = _pack_constants(self.constants_manifest) + constants = {str(getattr(expr, "name", index)): float(packed[index]) + for index, expr in self.constants_manifest + if expr is not clock} + except Exception: + constants = None + model._describe_part(self, part, label, constants=constants) model._record_step_event("solve", label, part=part) except Exception: pass diff --git a/src/underworld3/discretisation/discretisation_mesh.py b/src/underworld3/discretisation/discretisation_mesh.py index 2c91a9992..e9a58eb6d 100644 --- a/src/underworld3/discretisation/discretisation_mesh.py +++ b/src/underworld3/discretisation/discretisation_mesh.py @@ -1823,7 +1823,7 @@ def view(self, level=0, format=None): from underworld3.utilities.describe import view as _view if uw.mpi.rank == 0: _view(self, format=format) - if uw.is_notebook and uw.mpi.size == 1: + if uw.is_notebook() and uw.mpi.size == 1: uw.visualisation.plot_mesh(self, window_size=(600, 400)) elif level == 1: if uw.mpi.rank == 0: diff --git a/src/underworld3/model.py b/src/underworld3/model.py index c3e51d05c..0bc8adb94 100644 --- a/src/underworld3/model.py +++ b/src/underworld3/model.py @@ -164,7 +164,7 @@ class ModelStep: """ __slots__ = ("index", "t0", "dt", "label", "events", "completed", "snapshot", - "wall") + "wall", "abandoned_by") def __init__(self, index, t0, dt, label=None): self.index = index @@ -177,6 +177,10 @@ def __init__(self, index, t0, dt, label=None): # want when watching a run: a step that suddenly takes ten times as # long is the first sign of a solver in trouble. self.wall = None + # What stopped a step that did not commit: the exception's class and + # message, so the record says why a step was abandoned and not only + # that it was. + self.abandoned_by = None # The state this step STARTED from, when the recording policy kept one. # Taken before the operators ran, which is the only correct point: a # DDt shifts its history in its post-solve hook, so a snapshot taken @@ -223,6 +227,7 @@ def as_dict(self): "restorable": bool(self.restorable), "wall": None if self.wall is None else float(self.wall), "events": [dict(e) for e in self.events], + **({"abandoned_by": dict(self.abandoned_by)} if self.abandoned_by else {}), } def __repr__(self): @@ -1512,9 +1517,16 @@ def _trim_records(self): for entry in restorable[: max(0, len(restorable) - limit)]: entry.snapshot = None - def rewind(self, steps: int = 1): + def rewind(self, steps: int = 1, reason=None, **detail): """Go back to the state at the start of a completed step. + ``reason`` says why, in a word or a sentence — ``"timestep rejected"``, + ``"free surface displacement over the limit"`` — and ``detail`` carries + the numbers behind it (``observed=0.18, threshold=0.10, + action="halve dt"``). The transcript cannot infer either, since the + acceptance test lives in the caller's loop; recorded here, a reader + of the run sees the decision and not only the backtrack. + ``steps=1`` returns to the beginning of the most recent completed step, undoing it. Fields, histories and the clock all come back together, because the clock lives on the tracker and the tracker is captured with @@ -1553,10 +1565,13 @@ def rewind(self, steps: int = 1): to_step=int(target.index), steps_undone=int(dropped), t=_jsonable_quantity(self.tracker.time), + **({"reason": str(reason)} if reason is not None else {}), + **({"detail": {str(k): _jsonable_quantity(v) if hasattr(v, "magnitude") else v + for k, v in detail.items()}} if detail else {}), ) return target - def _describe_part(self, owner, part: str, label: str) -> None: + def _describe_part(self, owner, part: str, label: str, constants=None) -> None: """Record what a part SOLVES, not just that it ran. Underworld3's residuals are SymPy, so the weak form a solver assembles @@ -1576,7 +1591,11 @@ def _describe_part(self, owner, part: str, label: str) -> None: self._part_objects[part] = owner known = self._parts.get(part) rebuilding = not getattr(owner, "is_setup", True) - if known is not None and not rebuilding: + # a parameter's value is part of the equation as solved: a change + # re-reads the form even though nothing was rebuilt + changed = (known is not None and constants is not None + and known.get("constants") != constants) + if known is not None and not rebuilding and not changed: return described = None @@ -1598,6 +1617,8 @@ def _describe_part(self, owner, part: str, label: str) -> None: described["forms"][f].get("text", "") for f in sorted(described["forms"]) ) + if constants: + fingerprint += json.dumps(constants, sort_keys=True) if known is not None and known.get("fingerprint") == fingerprint: return @@ -1608,6 +1629,8 @@ def _describe_part(self, owner, part: str, label: str) -> None: "at_step": self._open_step.index, "fingerprint": fingerprint, } + if constants is not None: + record["constants"] = constants # the description's own kind and its contained objects stay out of # the record: a part record IS a kind, and the children are recorded # as parts of their own when they act @@ -1829,12 +1852,13 @@ def _restore(): _warnings.showwarning = _record_and_show try: yield record - except BaseException: + except BaseException as exc: _restore() record.wall = _time.monotonic() - wall0 # Abandon: put the clock back and do not commit. self.tracker.time = t0 record.completed = False + record.abandoned_by = {"type": type(exc).__name__, "message": str(exc)[:300]} self._open_step = None # The abandoned record never joins the transcript, so the state it # captured is unreachable — drop it rather than hold a field- diff --git a/src/underworld3/utilities/transcript_query.py b/src/underworld3/utilities/transcript_query.py new file mode 100644 index 000000000..86456d786 --- /dev/null +++ b/src/underworld3/utilities/transcript_query.py @@ -0,0 +1,312 @@ +r"""A run's transcript, queryable. + +The transcript on disk is a record of what a run did: a header with the +scales, one line per step with the operators it applied and how each went, +the description of each part when it first acted, and notes for the things +that were not steps, a rewind above all. :func:`underworld3.read_transcript` +reads it back as data; this module puts one query object over that data so +a notebook, a test, the digest and a tool all ask the same questions of the +same interpretation. + + t = uw.Transcript("transcripts/latest/transcript.jsonl") + t.view() # the summary, in the form the session wants + t.failed(), t.capped() # solves that diverged, solves with a block at its cap + t.abandoned(), t.backtracks() # rejected steps, and the rewinds with their reasons + t.patterns() # the run collapsed to its distinct step patterns + t.part("SNES_Stokes#3", at_step=40) # the equation that was being solved at step 40 + t.compare(12, 13) # what changed between two steps + +Every method returns plain data, the same dicts the record holds, with the +outcome of a solve read by the same rule the figure and the table use. +""" + +from .describe import record, view as _view + + +def _operator_text(event): + """A short name for what an event did: the solver's own name for a + solve, ``history shift T`` for a shift, ``advect swarm`` for a push.""" + kind, name = event.get("kind"), event.get("name") + if kind == "solve": + return str(name) + if kind == "adjoint_solve": + return f"adjoint {name}" + if kind == "history_shift": + return f"history shift {name}" + if kind == "swarm_advect": + return f"advect {name}" + return f"{kind} {name}" + + +class Transcript: + """One run, from a transcript file, the list :func:`read_transcript` + returns, or a live model. ``run`` picks a run when the file holds + several, the last by default.""" + + def __init__(self, source, run=-1): + from .transcript_report import _as_runs + if isinstance(source, Transcript): + runs = source.runs + else: + runs = _as_runs(source) + if not runs: + raise ValueError("this transcript holds no run") + self.runs = runs + self.entry = runs[run] + self.header = self.entry.get("run") or {} + self.steps = list(self.entry.get("steps") or []) + self.notes = list(self.entry.get("notes") or []) + self.parts = list(self.entry.get("parts") or []) + self.ended = self.entry.get("ended") + self.live = bool(self.entry.get("live")) + + # --- the shape of the run ------------------------------------------- + + def __len__(self): + return len(self.steps) + + def __iter__(self): + return iter(self.steps) + + @property + def name(self): + from .transcript_report import _run_title + return _run_title(self.header, fallback="") + + def step(self, index, attempt=-1): + """The step with this index, as recorded. After a rewind the run + numbers its steps again from where it went back to, so an index can + name several attempts: ``attempt`` picks one, the last by default, + which is the one that stands. :meth:`attempts` lists them all.""" + found = self.attempts(index) + if not found: + raise KeyError(f"no step {index} in this run") + return found[attempt] + + def attempts(self, index): + """Every recorded step with this index, in the order they happened: + the rejected ones first, the one that stands last.""" + return [s for s in self.steps if int(s.get("index", -1)) == int(index)] + + def position(self, step): + """Where a step record sits in the run's sequence.""" + for i, s in enumerate(self.steps): + if s is step: + return i + raise ValueError("this step is not in the run") + + def sequence(self, step): + """The operators a step applied, in order, as short names.""" + step = self.step(step) if not isinstance(step, dict) else step + return [_operator_text(e) for e in step.get("events", []) if e.get("kind") != "warning"] + + def outcome(self, event): + """``"ok"``, ``"capped"``, ``"diverged"`` or ``None`` for an event: the + rule the figure and the table apply.""" + from .transcript_report import _outcome + return _outcome(event) + + # --- events --------------------------------------------------------- + + def events(self, kind=None, part=None, outcome=None, step=None): + """Events across the run, each as ``(step_index, event)``, filtered + by kind (``"solve"``, ``"adjoint_solve"``, ``"history_shift"``, + ``"swarm_advect"``, ``"warning"``), by part, by outcome, or to one + step.""" + out = [] + for s in self.steps: + if step is not None and int(s.get("index", -1)) != int(step): + continue + for e in s.get("events", []): + if kind is not None and e.get("kind") != kind: + continue + if part is not None and e.get("part") != part and e.get("name") != part: + continue + if outcome is not None and self.outcome(e) != outcome: + continue + out.append((int(s.get("index", -1)), e)) + return out + + def solves(self, outcome=None, part=None): + return self.events(kind="solve", part=part, outcome=outcome) + + def failed(self): + """Solves that did not converge.""" + return self.solves(outcome="diverged") + + def capped(self): + """Solves the SNES called converged while an inner block hit its + iteration cap or its deadline: the amber mark.""" + return self.solves(outcome="capped") + + def warnings(self): + return self.events(kind="warning") + + def abandoned(self): + """Steps that did not commit: an exception inside the block, or a + step the caller rejected. Each carries ``abandoned_by`` when the + run recorded what stopped it.""" + return [s for s in self.steps if not s.get("completed")] + + def backtracks(self): + """The rewinds and restores, each with where in the sequence it + happened (``after_position``), the step it went back to + (``to_step``), and the ``reason`` and ``detail`` the caller gave.""" + return [n for n in self.notes if n.get("kind") in ("rewind", "restore")] + + # --- parts ----------------------------------------------------------- + + def part_names(self): + seen = [] + for p in self.parts: + if p.get("part") not in seen: + seen.append(p.get("part")) + return seen + + def part(self, name, at_step=None): + """The description of a part as recorded: what it solved. With + ``at_step``, the description in force at that step, which is the + latest recorded at or before it — a part records itself again when + its form changes.""" + records = [p for p in self.parts if p.get("part") == name or p.get("label") == name] + if not records: + raise KeyError(f"no part {name!r} recorded in this run") + if at_step is None: + return records[-1] + before = [p for p in records if int(p.get("at_step", -1)) <= int(at_step)] + return before[-1] if before else records[0] + + def changes(self): + """Parts whose form changed during the run: ``(part, at_step)`` for + each re-recording after the first.""" + seen, out = {}, [] + for p in self.parts: + key = p.get("part") + if key in seen and seen[key] != p.get("fingerprint"): + out.append((key, p.get("at_step"))) + seen[key] = p.get("fingerprint") + return out + + # --- structure ------------------------------------------------------- + + def patterns(self): + """The run collapsed to its distinct step patterns: consecutive + steps that applied the same operators in the same order with the + same outcomes, the same label and the same completion are one + pattern. Each is ``{"from", "to", "count", "sequence", "outcomes", + "label", "completed"}``; a run that never changes has one.""" + out = [] + for position, s in enumerate(self.steps): + signature = (tuple(self.sequence(s)), + tuple(self.outcome(e) for e in s.get("events", []) if e.get("kind") != "warning"), + s.get("label"), bool(s.get("completed"))) + index = int(s.get("index", -1)) + if (out and out[-1]["_signature"] == signature + and not self._note_between(out[-1]["positions"][1], position)): + out[-1]["to"] = index + out[-1]["positions"] = (out[-1]["positions"][0], position) + out[-1]["count"] += 1 + continue + out.append({"from": index, "to": index, "positions": (position, position), "count": 1, + "sequence": list(signature[0]), "outcomes": list(signature[1]), + "label": signature[2], "completed": signature[3], "_signature": signature}) + for p in out: + p.pop("_signature") + return out + + def _note_between(self, position_a, position_b): + """Whether a note (a rewind, a restore) sits between two positions.""" + return any(position_a <= int(n.get("after_position", -1)) < position_b for n in self.notes) + + def compare(self, a, b): + """What differs between two steps, given by index (the attempt that + stands) or as records from :meth:`step`: operators in one and not + the other, outcomes that changed for the same operator, and the + interval, wall time and completion of each.""" + sa = a if isinstance(a, dict) else self.step(a) + sb = b if isinstance(b, dict) else self.step(b) + seq_a, seq_b = self.sequence(sa), self.sequence(sb) + oa = {self._event_key(e): self.outcome(e) for e in sa.get("events", [])} + ob = {self._event_key(e): self.outcome(e) for e in sb.get("events", [])} + return { + "only_in_a": [x for x in seq_a if x not in seq_b], + "only_in_b": [x for x in seq_b if x not in seq_a], + "order_differs": seq_a != seq_b and sorted(seq_a) == sorted(seq_b), + "outcome_changes": {k: (oa[k], ob[k]) for k in oa if k in ob and oa[k] != ob[k]}, + "dt": (sa.get("dt"), sb.get("dt")), + "wall": (sa.get("wall"), sb.get("wall")), + "completed": (bool(sa.get("completed")), bool(sb.get("completed"))), + } + + @staticmethod + def _event_key(event): + return (event.get("kind"), event.get("part") or event.get("name")) + + def between(self, t0, t1): + """Steps whose interval starts in ``[t0, t1]``, in the run's own + time unit.""" + from .transcript_report import _magnitude + return [s for s in self.steps if t0 <= _magnitude(s.get("t0")) <= t1] + + def adjoint_segments(self): + """The run partitioned by adjoint support: see + :func:`underworld3.transcript_adjoint_segments`.""" + from .transcript_report import transcript_adjoint_segments + return transcript_adjoint_segments(self.runs, run=self.runs.index(self.entry)) + + # --- description --------------------------------------------------- + + def describe(self, depth=1): + """The run as data, in the shape every other object uses: facts for + the header and the counts, and the parts as children.""" + facts = {} + if self.header.get("started"): + facts["started"] = self.header["started"] + if self.ended: + facts["ended"] = self.ended.get("ended") or self.ended.get("at") or "yes" + elif self.live: + facts["state"] = "in progress" + else: + facts["state"] = "no terminator: still running, or interrupted" + scales = self.header.get("reference") or self.header.get("scales") or {} + if scales: + facts["scales"] = " | ".join(f"{k} {v['magnitude']:.4g} {v['units']}" + for k, v in scales.items() if isinstance(v, dict)) + facts["steps"] = len(self.steps) + abandoned = self.abandoned() + if abandoned: + facts["abandoned"] = [int(s.get("index", -1)) for s in abandoned] + backtracks = self.backtracks() + if backtracks: + facts["backtracks"] = len(backtracks) + failed, capped = self.failed(), self.capped() + if failed: + facts["failed solves"] = [f"{i}: {e.get('name')}" for i, e in failed] + if capped: + facts["capped solves"] = len(capped) + patterns = self.patterns() + facts["patterns"] = [f"{p['from']}-{p['to']}: " + " > ".join(p["sequence"]) if p["count"] > 1 + else f"{p['from']}: " + " > ".join(p["sequence"]) for p in patterns[:12]] + if len(patterns) > 12: + facts["patterns"].append(f"... {len(patterns) - 12} more") + children = [] + if depth > 0: + for p in self.parts: + child = dict(p) + child.setdefault("kind", "part") + child.setdefault("name", p.get("label") or p.get("part")) + child.setdefault("summary", f"recorded at step {p.get('at_step')}") + children.append(child) + summary = f"{len(self.steps)} step(s)" + if abandoned: + summary += f", {len(abandoned)} abandoned" + if backtracks: + summary += f", {len(backtracks)} backtrack(s)" + if failed: + summary += f", {len(failed)} failed solve(s)" + return record("transcript", self.name, summary, facts=facts, children=children) + + def view(self, format=None, depth=None): + """Show the run: :meth:`describe` rendered for the session, or in + the ``format`` named.""" + _view(self, format=format, depth=depth) diff --git a/src/underworld3/utilities/transcript_report.py b/src/underworld3/utilities/transcript_report.py index 60152ed32..4612bc59b 100644 --- a/src/underworld3/utilities/transcript_report.py +++ b/src/underworld3/utilities/transcript_report.py @@ -63,7 +63,10 @@ # --------------------------------------------------------------------------- def _as_runs(source): - """Accept a path, the list ``read_transcript`` returns, or a live model.""" + """Accept a path, the list ``read_transcript`` returns, a live model, or + a :class:`~underworld3.utilities.transcript_query.Transcript`.""" + if hasattr(source, "runs") and hasattr(source, "steps") and hasattr(source, "parts"): + return source.runs if isinstance(source, (str, os.PathLike)): import underworld3 as uw diff --git a/tests/test_0018_transcript_query.py b/tests/test_0018_transcript_query.py new file mode 100644 index 000000000..2f403b085 --- /dev/null +++ b/tests/test_0018_transcript_query.py @@ -0,0 +1,132 @@ +"""One query object over a run's record. + +``uw.Transcript`` reads a transcript back and answers the questions a +debugging session asks: which steps were abandoned and by what, where the +run went back and why, which solves failed or ran capped, what the run's +step patterns were, what a part was solving at a given step, and what +changed between two steps. The digest, a notebook and a tool all read the +same interpretation. The record gains two things here: the exception that +abandoned a step, and the reason and detail a caller gives a rewind. +""" +import json + +import pytest +import sympy + +import underworld3 as uw + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + + +def _run(tmp_path): + uw.reset_default_model() + model = uw.get_default_model() + path = tmp_path / "run.jsonl" + model.transcript_file = str(path) + model.record_every = 1 + mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), + cellSize=1 / 4, qdegree=2) + u = uw.discretisation.MeshVariable("u", mesh, 1, degree=1) + kappa = uw.expression(r"\kappa", 1.0, "diffusivity") + poisson = uw.systems.Poisson(mesh, u_Field=u) + poisson.constitutive_model = uw.constitutive_models.DiffusionModel + poisson.constitutive_model.Parameters.diffusivity = kappa + poisson.f = 1.0 + poisson.add_essential_bc(0.0, "Bottom") + poisson.add_essential_bc(1.0, "Top") + poisson.petsc_options.delValue("ksp_monitor") + + for _ in range(3): + with model.step(0.1, label="march"): + poisson.solve() + model._record_step_event("history_shift", "T_history") + # a step rejected by the caller's own test, with the reason recorded + with pytest.raises(RuntimeError): + with model.step(0.1, label="too far"): + poisson.solve() + raise RuntimeError("displacement 0.18 over the limit 0.10") + model.rewind(1, reason="displacement over the limit", observed=0.18, threshold=0.10, + action="halve dt") + poisson.constitutive_model.Parameters.diffusivity = 2 * kappa # the form changes: the part records itself again + with model.step(0.05, label="retry"): + poisson.solve() + model._record_step_event("history_shift", "T_history") + with model.step(0.05, label="retry"): + poisson.solve() + model._record_step_event("history_shift", "T_history") + return model, path, poisson + + +def test_the_record_carries_why(tmp_path): + model, path, poisson = _run(tmp_path) + lines = [json.loads(l) for l in path.read_text().splitlines()] + abandoned = [l for l in lines if l.get("kind") == "step" and not l.get("completed")] + assert abandoned and abandoned[0]["abandoned_by"]["type"] == "RuntimeError" + assert "over the limit" in abandoned[0]["abandoned_by"]["message"] + rewinds = [l for l in lines if l.get("kind") == "rewind"] + assert rewinds[0]["reason"] == "displacement over the limit" + assert rewinds[0]["detail"] == {"observed": 0.18, "threshold": 0.10, "action": "halve dt"} + + +def test_the_queries_answer_from_the_file(tmp_path): + model, path, poisson = _run(tmp_path) + t = uw.Transcript(str(path)) + assert len(t) == 6 # 3 marches, 1 abandoned, 2 retries + assert [s["index"] for s in t.abandoned()] == [3] + back = t.backtracks() + assert len(back) == 1 and back[0]["reason"] == "displacement over the limit" + assert back[0]["to_step"] == 2 and back[0]["after_position"] == 3 + assert t.failed() == [] and t.capped() == [] + assert len(t.solves()) == 6 + assert t.sequence(0) == ["Poisson(u)", "history shift T_history"] or len(t.sequence(0)) == 2 + patterns = t.patterns() + # marches collapse, the abandoned step stands alone, the retries collapse + assert [p["count"] for p in patterns] == [3, 1, 2], patterns + assert patterns[0]["from"] == 0 and patterns[0]["to"] == 2 and patterns[2]["label"] == "retry" + # after the rewind the run numbered its retries 2 and 3 again: the + # rejected step 3 and the retry that stands are two attempts + assert len(t.attempts(3)) == 2 and t.step(3)["completed"] and not t.step(3, attempt=0)["completed"] + diff = t.compare(t.step(2, attempt=0), t.step(3, attempt=0)) + assert diff["completed"] == (True, False) + # the rejected step stopped before its history shift + assert diff["only_in_a"] == ["history shift T_history"] and diff["only_in_b"] == [] + diff = t.compare(0, 2) # the march at 0 against the retry that stands at 2 + dt = [v["magnitude"] if isinstance(v, dict) else v for v in diff["dt"]] # a dimensional run holds {magnitude, units} + assert dt == [pytest.approx(0.1), pytest.approx(0.05)] + + +def test_a_part_is_read_at_a_step(tmp_path): + model, path, poisson = _run(tmp_path) + t = uw.Transcript(str(path)) + names = t.part_names() + assert len(names) == 1 + first = t.part(names[0], at_step=0) + last = t.part(names[0], at_step=5) + assert first["fingerprint"] != last["fingerprint"], "the flux changed before the retry" + assert [c[0] for c in t.changes()] == [names[0]] + assert "F1" in first["forms"] + + +def test_the_transcript_describes_and_renders(tmp_path, capsys): + model, path, poisson = _run(tmp_path) + t = uw.Transcript(str(path)) + d = t.describe() + assert d["kind"] == "transcript" and d["facts"]["steps"] == 6 + assert d["facts"]["abandoned"] == [3] and d["facts"]["backtracks"] == 1 + assert d["children"] and d["children"][0]["kind"] == "part" + for fmt in ("markdown", "text", "yaml", "json"): + assert uw.render(d, fmt).strip() + t.view(format="text") + assert "transcript" in capsys.readouterr().out + # the renderers take the query object as a source + assert "Poisson" in uw.transcript_key(t, format="text") + assert uw.transcript_table(t) + + +def test_a_live_model_is_a_source_too(tmp_path): + model, path, poisson = _run(tmp_path) + t = uw.Transcript(model) + assert t.live + # the file keeps the abandoned step and the one the rewind undid; the + # live list holds only what stands + assert len(t) == 6 and len(model.transcript) == 4 From 92e5fb29a6f0679573d17f0f6539bf395804b8bf Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sat, 26 Sep 2026 21:31:47 -0700 Subject: [PATCH 3/9] A local, read-only MCP server over run transcripts python -m underworld3.mcp speaks MCP on stdio and answers questions about runs from their transcripts: the runs under a directory, one run's summary, its steps paged, its step patterns, everything that went wrong or went back, events filtered by kind, part, outcome or step, one step with its attempts, what differs between two steps, the parts that acted, what a part was solving at a step in three levels of detail, the key, the adjoint segments, and any description record rendered in another form. Every tool is a projection of uw.Transcript and the description layer and returns the query's own answer as YAML, so what a model is told is what the digest, a notebook and a test read. Nothing runs a model or writes to one; every tool is annotated read-only. The repository's .mcp.json registers it for Claude Code under the name "underworld" through scripts/mcp-server.sh, which starts it in the checkout's pixi environment. The mcp package (2.x) is not in pixi.toml yet; the guide says how to install it into an environment meanwhile, and the test skips without it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V --- .mcp.json | 8 + docs/developer/guides/mcp-server.md | 60 ++++++ docs/developer/index.md | 1 + scripts/mcp-server.sh | 9 + src/underworld3/mcp/__init__.py | 305 ++++++++++++++++++++++++++++ src/underworld3/mcp/__main__.py | 4 + tests/test_0019_mcp_server.py | 98 +++++++++ 7 files changed, 485 insertions(+) create mode 100644 .mcp.json create mode 100644 docs/developer/guides/mcp-server.md create mode 100755 scripts/mcp-server.sh create mode 100644 src/underworld3/mcp/__init__.py create mode 100644 src/underworld3/mcp/__main__.py create mode 100644 tests/test_0019_mcp_server.py diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..0c7a2b122 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "underworld": { + "command": "scripts/mcp-server.sh", + "args": [] + } + } +} diff --git a/docs/developer/guides/mcp-server.md b/docs/developer/guides/mcp-server.md new file mode 100644 index 000000000..439fa0db7 --- /dev/null +++ b/docs/developer/guides/mcp-server.md @@ -0,0 +1,60 @@ +# The transcript MCP server + +A local, read-only MCP server lets an AI assistant ask narrow questions +about a run from its transcript instead of reading the file, and instead +of reconstructing the model from source. It is a thin projection of +`uw.Transcript` and the description layer: every tool reads a transcript +and returns the query's own answer as YAML, so what the assistant is told +is what the digest, a notebook and a test read. Nothing in it runs a +model or writes to one. + +## Running it + +```bash +python -m underworld3.mcp # speaks MCP on stdio +``` + +The repository's `.mcp.json` registers it for Claude Code under the name +`underworld`, through `scripts/mcp-server.sh`, which starts it in the +checkout's pixi environment. The server needs the `mcp` package (2.x); +until it is in `pixi.toml`, install it into the environment once: + +```bash +pixi run -e python -m pip install mcp +``` + +## The tools + +| tool | answers | +|---|---| +| `uw_transcript_list` | runs under a directory, newest first, with steps, abandoned and backtrack counts | +| `uw_transcript_summary` | one run: scales, counts, patterns, the parts by name | +| `uw_transcript_steps` | rows in sequence order, paged: index, label, dt, wall, operators, outcomes | +| `uw_transcript_patterns` | the run collapsed to its distinct step patterns | +| `uw_transcript_problems` | abandoned steps with what stopped them, backtracks with reasons, failed and capped solves | +| `uw_transcript_events` | events filtered by kind, part, outcome or step | +| `uw_transcript_step` | one step, with `attempt` for a step rewound and retried | +| `uw_transcript_compare` | what differs between two steps | +| `uw_transcript_parts` | the solvers that acted, and when their form or values changed | +| `uw_transcript_part` | what a part was solving at a step: `summary`, `forms` (LaTeX), or `exact` | +| `uw_transcript_key` | the key to the run, Markdown or text | +| `uw_transcript_adjoint_segments` | the run partitioned by adjoint support | +| `uw_describe_render` | any description record in another form | + +`path` may be a transcript file, a run directory, or a `transcripts` +directory, in which case the latest run is read. + +## Why it is shaped this way + +The answers are compact by default and drill down on request: a summary +names the parts, `uw_transcript_part` with `detail="summary"` gives the +terms and values, `detail="forms"` the residual as LaTeX, `detail="exact"` +the whole record. Large symbolic expressions reach the assistant only when +asked for. The interpretation is the transcript's own: a solve is +`capped` here exactly when the figure marks it amber. + +The server reads files, so it answers about runs that have happened, on +this machine, including runs still in progress. Live-object introspection +(`stokes.view()`, `stokes.adjoint_view()`) stays in the session that owns +the objects; the part records carry the same description the live objects +give, so the two agree. diff --git a/docs/developer/index.md b/docs/developer/index.md index 2462b1f1f..d74b816d4 100644 --- a/docs/developer/index.md +++ b/docs/developer/index.md @@ -131,6 +131,7 @@ guides/notebook-style-guide guides/GMSH_INTEGRATION_GUIDE guides/CODE-REVIEW-PROCESS guides/adversarial-review +guides/mcp-server guides/style-gates guides/SPELLING_CONVENTION guides/version-management diff --git a/scripts/mcp-server.sh b/scripts/mcp-server.sh new file mode 100755 index 000000000..cf1fe7e26 --- /dev/null +++ b/scripts/mcp-server.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Start the Underworld3 transcript MCP server in this checkout's pixi +# environment (the one ./uw setup recorded in .pixi-env). Claude Code runs +# this through .mcp.json; the server speaks MCP on stdio. +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +env_name="$(cat "$here/.pixi-env" 2>/dev/null || echo default)" +export PIXI_PROJECT_MANIFEST="$here/pixi.toml" +exec pixi run -e "$env_name" python -m underworld3.mcp diff --git a/src/underworld3/mcp/__init__.py b/src/underworld3/mcp/__init__.py new file mode 100644 index 000000000..9194009c3 --- /dev/null +++ b/src/underworld3/mcp/__init__.py @@ -0,0 +1,305 @@ +r"""A local, read-only MCP server over run transcripts. + +The server is a thin projection of :class:`underworld3.Transcript` and the +description layer: every tool reads a transcript file and returns the +query's own answer as YAML, so what a tool tells a model is what the +digest, a notebook and a test read. Nothing here runs a model or writes +to one. + +Run it with ``python -m underworld3.mcp`` (stdio). The repository's +``.mcp.json`` registers it for Claude Code under the name ``underworld``. + +Tools take a ``path`` that may be a transcript file, a run directory that +holds ``transcript.jsonl``, or a ``transcripts`` directory, in which case +the latest run is read. Paths are resolved against the working directory +the server was started in. +""" + +import glob +import os + +from mcp.server.mcpserver import MCPServer +from mcp.types import ToolAnnotations + +from ..utilities.describe import plain, render +from ..utilities.transcript_query import Transcript + +server = MCPServer( + "underworld", + instructions=( + "Read-only questions about Underworld3 runs, answered from their transcripts. " + "Start with uw_transcript_list to find runs and uw_transcript_summary for one run; " + "use uw_transcript_problems for what went wrong, uw_transcript_part for the equation " + "a solver was solving, with detail='exact' only when the full form is needed." + ), +) + +_READ_ONLY = ToolAnnotations(read_only_hint=True, destructive_hint=False, + idempotent_hint=True, open_world_hint=False) + + +def _yaml(value): + import yaml + return yaml.safe_dump(plain(value), sort_keys=False, allow_unicode=True, width=100) + + +def resolve(path): + """The transcript file a path names: a file, a run directory, or a + ``transcripts`` directory whose latest run is wanted.""" + p = os.path.expanduser(str(path or ".")) + if os.path.isdir(p): + for candidate in (os.path.join(p, "transcript.jsonl"), + os.path.join(p, "latest", "transcript.jsonl"), + os.path.join(p, "transcripts", "latest", "transcript.jsonl")): + if os.path.exists(candidate): + return candidate + runs = sorted(glob.glob(os.path.join(p, "*", "transcript.jsonl"))) + if runs: + return runs[-1] + raise FileNotFoundError( + f"no transcript under {p!r}: pass a transcript.jsonl, a run directory, or a " + f"'transcripts' directory (uw_transcript_list shows what is there)") + if os.path.exists(p): + return p + raise FileNotFoundError(f"{p!r} does not exist; uw_transcript_list finds transcripts under a directory") + + +def _transcript(path, run=-1): + return Transcript(resolve(path), run=run) + + +def _magnitude(value): + if isinstance(value, dict): + return value.get("magnitude") + return value + + +def _row(t, position, step): + events = [e for e in step.get("events", []) if e.get("kind") != "warning"] + return { + "position": position, + "index": step.get("index"), + "label": step.get("label"), + "t0": _magnitude(step.get("t0")), + "dt": _magnitude(step.get("dt")), + "wall_s": step.get("wall"), + "completed": bool(step.get("completed")), + "operators": t.sequence(step), + "outcomes": [t.outcome(e) for e in events], + **({"abandoned_by": step["abandoned_by"]} if step.get("abandoned_by") else {}), + } + + +@server.tool(name="uw_transcript_list", annotations=_READ_ONLY) +def uw_transcript_list(directory: str = ".", limit: int = 20) -> str: + """Find run transcripts under a directory (recursively, newest first): + the file, when the run started, how many steps it holds and whether it + ended. Use this first when the path of a run is not known.""" + root = os.path.expanduser(directory) + files = sorted(glob.glob(os.path.join(root, "**", "transcript.jsonl"), recursive=True), + key=os.path.getmtime, reverse=True) + out = [] + for f in files[:max(1, limit)]: + try: + t = Transcript(f) + out.append({"path": f, "run": t.name, "started": t.header.get("started"), + "steps": len(t), "ended": bool(t.ended), + "abandoned": len(t.abandoned()), "backtracks": len(t.backtracks())}) + except Exception as exc: + out.append({"path": f, "error": f"{type(exc).__name__}: {exc}"}) + if not out: + return f"no transcript.jsonl under {root!r}" + return _yaml(out) + + +@server.tool(name="uw_transcript_summary", annotations=_READ_ONLY) +def uw_transcript_summary(path: str = ".", run: int = -1) -> str: + """One run in summary: when it ran, its scales as declared, how many + steps, which were abandoned, how many backtracks, failed and capped + solves, and the run's step patterns. The parts (solvers) are listed by + name; uw_transcript_part gives what each solved.""" + t = _transcript(path, run) + d = t.describe(depth=1) + d["children"] = [{"part": c.get("part"), "label": c.get("label"), "solver": c.get("solver"), + "unknown": c.get("unknown"), "recorded_at_step": c.get("at_step")} + for c in d.get("children", [])] + d["parts"] = d.pop("children") + d["file"] = resolve(path) + return _yaml(d) + + +@server.tool(name="uw_transcript_steps", annotations=_READ_ONLY) +def uw_transcript_steps(path: str = ".", start: int = 0, count: int = 40, run: int = -1) -> str: + """Steps in sequence order from position `start`, `count` at a time: + index, label, t0, dt, wall time, completion, the operators applied and + the outcome of each. Indices repeat after a rewind, which is why rows + carry a position as well.""" + t = _transcript(path, run) + rows = [_row(t, i, s) for i, s in enumerate(t.steps)][start:start + max(1, count)] + return _yaml({"total": len(t), "from": start, "rows": rows}) + + +@server.tool(name="uw_transcript_patterns", annotations=_READ_ONLY) +def uw_transcript_patterns(path: str = ".", run: int = -1) -> str: + """The run collapsed to its distinct step patterns: consecutive steps + with the same operators, outcomes, label and completion, with nothing + recorded between them, are one pattern with a count. A run that never + changes has one; each extra pattern is something that happened.""" + return _yaml(_transcript(path, run).patterns()) + + +@server.tool(name="uw_transcript_problems", annotations=_READ_ONLY) +def uw_transcript_problems(path: str = ".", run: int = -1) -> str: + """Everything that went wrong or went back: abandoned steps with what + stopped them, rewinds and restores with the reason and detail the + caller gave, solves that diverged, solves that ran with an inner block + at its iteration cap, and the count of warnings.""" + t = _transcript(path, run) + return _yaml({ + "abandoned": [{"position": t.position(s), "index": s.get("index"), "label": s.get("label"), + "abandoned_by": s.get("abandoned_by")} for s in t.abandoned()], + "backtracks": [{k: v for k, v in n.items() if k not in ("kind",)} for n in t.backtracks()], + "failed": [{"step": i, "solver": e.get("name"), "reason": e.get("reason"), + "nonlinear_its": e.get("nl_its")} for i, e in t.failed()], + "capped": [{"step": i, "solver": e.get("name"), "capped": e.get("capped"), + "deadline_expired": e.get("deadline_expired", False)} for i, e in t.capped()], + "warnings": len(t.warnings()), + }) + + +@server.tool(name="uw_transcript_events", annotations=_READ_ONLY) +def uw_transcript_events(path: str = ".", kind: str = "", part: str = "", outcome: str = "", + step: int = -1, limit: int = 100, run: int = -1) -> str: + """Events across the run, filtered: kind is solve, adjoint_solve, + history_shift, swarm_advect or warning; outcome is ok, capped or + diverged; part is a part id or a solver's label; step limits to one + step index. Each event comes with its step index and the record's own + fields (converged, reason, iterations, residual norms).""" + t = _transcript(path, run) + found = t.events(kind=kind or None, part=part or None, outcome=outcome or None, + step=None if step < 0 else step) + rows = [{"step": i, **e} for i, e in found[:max(1, limit)]] + return _yaml({"total": len(found), "events": rows}) + + +@server.tool(name="uw_transcript_step", annotations=_READ_ONLY) +def uw_transcript_step(path: str, index: int, attempt: int = -1, run: int = -1) -> str: + """One step as recorded, events and all. After a rewind an index can + name several attempts: attempt=-1 is the one that stands, attempt=0 the + first (rejected) one.""" + t = _transcript(path, run) + try: + s = t.step(index, attempt=attempt) + except (KeyError, IndexError) as exc: + return f"error: {exc}; the run has {len(t)} steps (uw_transcript_steps lists them)" + return _yaml({"position": t.position(s), "row": _row(t, t.position(s), s), "record": s}) + + +@server.tool(name="uw_transcript_compare", annotations=_READ_ONLY) +def uw_transcript_compare(path: str, a: int, b: int, attempt_a: int = -1, attempt_b: int = -1, + run: int = -1) -> str: + """What differs between two steps: operators in one and not the other, + outcomes that changed for the same operator, and the interval, wall + time and completion of each.""" + t = _transcript(path, run) + try: + return _yaml(t.compare(t.step(a, attempt_a), t.step(b, attempt_b))) + except (KeyError, IndexError) as exc: + return f"error: {exc}" + + +@server.tool(name="uw_transcript_parts", annotations=_READ_ONLY) +def uw_transcript_parts(path: str = ".", run: int = -1) -> str: + """The parts of the run, the solvers that acted, each with its label, + the step it was first recorded at, its unknown and its boundary + conditions in one line, and the steps at which its form or its + parameter values changed.""" + t = _transcript(path, run) + out = [] + for name in t.part_names(): + p = t.part(name) + out.append({"part": name, "label": p.get("label"), "solver": p.get("solver"), + "unknown": p.get("unknown"), "dim": p.get("dim"), + "recorded_at_step": p.get("at_step"), + "boundary_conditions": [f"{bc.get('type')} on {bc.get('boundary')}" + for bc in p.get("boundary_conditions", [])], + "changed_at_steps": [s for n, s in t.changes() if n == name]}) + return _yaml(out) + + +@server.tool(name="uw_transcript_part", annotations=_READ_ONLY) +def uw_transcript_part(path: str, part: str, at_step: int = -1, detail: str = "summary", + run: int = -1) -> str: + """What a part was solving, as recorded: with at_step, the description + in force at that step. detail='summary' gives the solver, unknown, + boundary conditions, the named terms with values and units, and the + run-time constants; detail='forms' adds the residual templates as + LaTeX with the named expressions inside them; detail='exact' returns + the whole record, which is large.""" + t = _transcript(path, run) + try: + p = t.part(part, at_step=None if at_step < 0 else at_step) + except KeyError as exc: + return f"error: {exc}; parts are {t.part_names()}" + if detail == "exact": + return _yaml(p) + out = {"part": p.get("part"), "label": p.get("label"), "solver": p.get("solver"), + "unknown": p.get("unknown"), "dim": p.get("dim"), "recorded_at_step": p.get("at_step"), + "boundary_conditions": [{"type": bc.get("type"), "boundary": bc.get("boundary"), + "value": bc.get("text")} for bc in p.get("boundary_conditions", [])], + "terms": [{"name": x.get("name"), "value": x.get("text"), "units": x.get("units"), + "description": x.get("description")} for x in (p.get("terms") or [])], + "constants": p.get("constants")} + if detail == "forms": + out["forms"] = {name: {"symbol": f.get("symbol"), "latex": f.get("latex"), + "description": f.get("description"), + "where": [f"{w.get('symbol')} = {w.get('value')}" + + (f" {w.get('units')}" if w.get("units") else "") + + (f", {w.get('description')}" if w.get("description") else "") + for w in f.get("where", [])]} + for name, f in (p.get("forms") or {}).items()} + elif detail != "summary": + return "error: detail must be 'summary', 'forms' or 'exact'" + return _yaml(out) + + +@server.tool(name="uw_transcript_key", annotations=_READ_ONLY) +def uw_transcript_key(path: str = ".", format: str = "text", run: int = -1) -> str: + """The key to the run: every part's residual as implemented, the named + expressions inside it with values and units, and its boundary + conditions, as Markdown with LaTeX or as plain text. Long for a run + with several solvers; uw_transcript_part reads one.""" + from ..utilities.transcript_report import transcript_key + if format not in ("markdown", "text"): + return "error: format must be 'markdown' or 'text'" + return transcript_key(_transcript(path, run), format=format) + + +@server.tool(name="uw_transcript_adjoint_segments", annotations=_READ_ONLY) +def uw_transcript_adjoint_segments(path: str = ".", run: int = -1) -> str: + """The run partitioned by adjoint support: the stretches over which + every operator can be differentiated, and the refusals between them + with their reasons.""" + return _yaml(_transcript(path, run).adjoint_segments()) + + +@server.tool(name="uw_describe_render", annotations=_READ_ONLY) +def uw_describe_render(record_yaml: str, format: str = "markdown", depth: int = -1) -> str: + """Render a description record (as returned by any uw_transcript tool + or by an object's describe()) in another form: markdown, text, latex, + yaml or json. depth limits levels of children; -1 renders all.""" + import yaml + try: + record = yaml.safe_load(record_yaml) + except Exception as exc: + return f"error: not YAML: {exc}" + if not isinstance(record, dict): + return "error: the record must be a mapping with kind, name and summary" + try: + return render(record, format, depth=None if depth < 0 else depth) + except ValueError as exc: + return f"error: {exc}" + + +def main(): + server.run(transport="stdio") diff --git a/src/underworld3/mcp/__main__.py b/src/underworld3/mcp/__main__.py new file mode 100644 index 000000000..f8b06a92b --- /dev/null +++ b/src/underworld3/mcp/__main__.py @@ -0,0 +1,4 @@ +"""``python -m underworld3.mcp``: the read-only transcript server on stdio.""" +from . import main + +main() diff --git a/tests/test_0019_mcp_server.py b/tests/test_0019_mcp_server.py new file mode 100644 index 000000000..06f952146 --- /dev/null +++ b/tests/test_0019_mcp_server.py @@ -0,0 +1,98 @@ +"""The transcript MCP server is a projection of the query object. + +Every tool reads a transcript file and returns the query's answer as YAML. +The tests call the tool functions directly on a small recorded run, and +check that the server registers them with read-only annotations. +""" +import asyncio + +import pytest +import yaml + +import underworld3 as uw + +pytest.importorskip("mcp") +from underworld3 import mcp as uwmcp # noqa: E402 + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + + +@pytest.fixture(scope="module") +def run_path(tmp_path_factory): + tmp = tmp_path_factory.mktemp("mcp") + uw.reset_default_model() + model = uw.get_default_model() + path = tmp / "transcripts" / "2026-09-26T00-00-00-run" / "transcript.jsonl" + model.transcript_file = str(path) + model.record_every = 1 + mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), cellSize=1 / 4, qdegree=2) + u = uw.discretisation.MeshVariable("u", mesh, 1, degree=1) + poisson = uw.systems.Poisson(mesh, u_Field=u) + poisson.constitutive_model = uw.constitutive_models.DiffusionModel + poisson.constitutive_model.Parameters.diffusivity = 1.0 + poisson.f = 1.0 + poisson.add_essential_bc(0.0, "Bottom") + poisson.add_essential_bc(1.0, "Top") + poisson.petsc_options.delValue("ksp_monitor") + for _ in range(2): + with model.step(0.1, label="march"): + poisson.solve() + with pytest.raises(RuntimeError): + with model.step(0.1, label="bad"): + poisson.solve() + raise RuntimeError("rejected by the test") + model.rewind(1, reason="rejected", observed=1.0, threshold=0.5) + with model.step(0.05, label="retry"): + poisson.solve() + return path + + +def test_the_server_registers_read_only_tools(): + tools = asyncio.run(uwmcp.server.list_tools()) + names = {t.name for t in tools} + assert {"uw_transcript_list", "uw_transcript_summary", "uw_transcript_problems", + "uw_transcript_part", "uw_transcript_key", "uw_describe_render"} <= names + assert all(t.annotations.read_only_hint for t in tools) + assert all(t.description for t in tools) + + +def test_paths_resolve_to_the_latest_run(run_path): + root = run_path.parent.parent # the 'transcripts' directory + assert uwmcp.resolve(str(root)) == str(run_path) + assert uwmcp.resolve(str(run_path.parent)) == str(run_path) + with pytest.raises(FileNotFoundError, match="uw_transcript_list"): + uwmcp.resolve(str(root / "nowhere")) + + +def test_the_tools_answer_as_yaml(run_path): + p = str(run_path) + listed = yaml.safe_load(uwmcp.uw_transcript_list(str(run_path.parent.parent))) + assert listed[0]["steps"] == 4 and listed[0]["abandoned"] == 1 + summary = yaml.safe_load(uwmcp.uw_transcript_summary(p)) + assert summary["kind"] == "transcript" and summary["facts"]["steps"] == 4 + assert summary["parts"][0]["solver"] == "SNES_Poisson" + steps = yaml.safe_load(uwmcp.uw_transcript_steps(p, start=0, count=2)) + assert steps["total"] == 4 and [r["position"] for r in steps["rows"]] == [0, 1] + patterns = yaml.safe_load(uwmcp.uw_transcript_patterns(p)) + assert [q["count"] for q in patterns] == [2, 1, 1] + problems = yaml.safe_load(uwmcp.uw_transcript_problems(p)) + assert problems["abandoned"][0]["abandoned_by"]["type"] == "RuntimeError" + assert problems["backtracks"][0]["reason"] == "rejected" + assert problems["failed"] == [] and problems["capped"] == [] + events = yaml.safe_load(uwmcp.uw_transcript_events(p, kind="solve", outcome="ok")) + assert events["total"] == 4 + step = yaml.safe_load(uwmcp.uw_transcript_step(p, index=2, attempt=0)) + assert step["row"]["completed"] is False + assert "error" in uwmcp.uw_transcript_step(p, index=99) + compare = yaml.safe_load(uwmcp.uw_transcript_compare(p, a=0, b=1)) # 1: the retry that stands + assert compare["completed"] == [True, True] + parts = yaml.safe_load(uwmcp.uw_transcript_parts(p)) + assert parts[0]["boundary_conditions"] == ["essential on Bottom", "essential on Top"] + part = yaml.safe_load(uwmcp.uw_transcript_part(p, part=parts[0]["part"], detail="forms")) + assert "F1" in part["forms"] and part["terms"] + exact = yaml.safe_load(uwmcp.uw_transcript_part(p, part=parts[0]["label"], detail="exact")) + assert exact["kind"] == "part" + assert "error" in uwmcp.uw_transcript_part(p, part="nothing") + assert "Poisson" in uwmcp.uw_transcript_key(p, format="text") + rendered = uwmcp.uw_describe_render(uwmcp.uw_transcript_summary(p), format="markdown") + assert rendered.startswith("## transcript") From 4c966010cd6b2cf4c99195aea59fdcf34b903c3d Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sun, 27 Sep 2026 10:06:44 -0700 Subject: [PATCH 4/9] describe_class(): the family's equation, terms and conditions from the class; view() on a class renders it view() on a class rendered the docstring and nothing else, and the instance view rendered the description: two paths. Now a family describes itself with no instance and no mesh. describe_class() reads what the class declares: the residual templates with their symbols and descriptions, the terms in _solver_terms, the add_*_bc methods it accepts, a constitutive model's parameter descriptors with symbol, units and description, a history scheme's defaults, and the docstring as its documentation. view() on a class renders that record, and class_documentation=True on an instance renders the family before the instance, so the two views are one renderer over two records. The MCP server gains uw_capabilities, the catalogue of every solver, constitutive model and history family built from those records, and uw_capability for one family in full. "Can Underworld solve this" is answered from the classes and cannot drift from them. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V --- docs/developer/guides/mcp-server.md | 2 + .../developer/subsystems/describe-and-view.md | 20 ++++++ src/underworld3/constitutive_models.py | 23 ++++++ .../cython/petsc_generic_snes_solvers.pyx | 63 ++++++++++++++++ src/underworld3/mcp/__init__.py | 72 +++++++++++++++++++ src/underworld3/systems/ddt.py | 17 +++++ src/underworld3/utilities/_api_tools.py | 36 ++++++---- src/underworld3/utilities/describe.py | 29 +++++++- tests/test_0017_describe_and_render.py | 35 +++++++++ tests/test_0019_mcp_server.py | 14 ++++ 10 files changed, 295 insertions(+), 16 deletions(-) diff --git a/docs/developer/guides/mcp-server.md b/docs/developer/guides/mcp-server.md index 439fa0db7..f59c58bf6 100644 --- a/docs/developer/guides/mcp-server.md +++ b/docs/developer/guides/mcp-server.md @@ -40,6 +40,8 @@ pixi run -e python -m pip install mcp | `uw_transcript_key` | the key to the run, Markdown or text | | `uw_transcript_adjoint_segments` | the run partitioned by adjoint support | | `uw_describe_render` | any description record in another form | +| `uw_capabilities` | every solver family with its equation templates, terms and conditions, every constitutive model with its parameters, every history scheme | +| `uw_capability` | one family in full, documentation included, as markdown, text or yaml | `path` may be a transcript file, a run directory, or a `transcripts` directory, in which case the latest run is read. diff --git a/docs/developer/subsystems/describe-and-view.md b/docs/developer/subsystems/describe-and-view.md index f6e27b2a5..e21bd4ce6 100644 --- a/docs/developer/subsystems/describe-and-view.md +++ b/docs/developer/subsystems/describe-and-view.md @@ -61,6 +61,26 @@ them as a nested "where" list under each form. `view()` on a class, or `view(class_documentation=True)` on an instance, shows the class documentation as well. +## At the class level + +A family describes itself with no instance and no mesh: + +```python +uw.systems.Stokes.view() # the equation templates, terms, conditions, documentation +uw.systems.Stokes.describe_class() # the same as data +uw.constitutive_models.ViscoPlasticFlowModel.describe_class() # parameters with symbol, units, description +uw.systems.ddt.SemiLagrangian.describe_class() # the scheme and its defaults +``` + +`describe_class()` reads what the class declares: the residual templates +(`F0`, `F1`, `PF0`) with their symbols and descriptions, `_solver_terms`, +the `add_*_bc` methods, the parameter descriptors of a constitutive +model's `_Parameters`, and the docstring, which becomes `documentation`. +`view()` on a class renders it, and `view(class_documentation=True)` on an +instance renders the family before the instance. This is what the +capabilities catalogue on the MCP server is built from, so "can Underworld +solve this" is answered from the classes and cannot drift from them. + ## Adding a description to a class Override `describe(self, depth=4)` and return a record built with diff --git a/src/underworld3/constitutive_models.py b/src/underworld3/constitutive_models.py index 0d1067aee..6b2499650 100644 --- a/src/underworld3/constitutive_models.py +++ b/src/underworld3/constitutive_models.py @@ -739,6 +739,29 @@ def _build_c_tensor(self): return + @classmethod + def describe_class(cls, depth=4): + """The family: its parameters, with symbol, units and description, + from the descriptors on its ``_Parameters`` class, and its + documentation — with no instance.""" + from underworld3.utilities.describe import record + from underworld3.utilities._api_tools import ExpressionDescriptor + doc = (cls.__doc__ or "").strip() + terms = [] + params = getattr(cls, "_Parameters", None) + if params is not None: + seen = set() + for base in params.__mro__: + for key, attr in base.__dict__.items(): + if isinstance(attr, ExpressionDescriptor) and key not in seen: + seen.add(key) + terms.append({"name": key, "symbol": getattr(attr, "name", None), "latex": None, + "text": None, "units": getattr(attr, "units", None), + "description": (getattr(attr, "description", "") or "").strip(), + "where": []}) + return record("constitutive_model_family", cls.__name__, doc.split("\n")[0], + documentation=doc or None, terms=terms or None) + def describe(self, depth=4): """What this constitutive model is, as data: its parameters as terms, with the named expressions inside each followed to ``depth``, and diff --git a/src/underworld3/cython/petsc_generic_snes_solvers.pyx b/src/underworld3/cython/petsc_generic_snes_solvers.pyx index 10841d936..6e1255330 100644 --- a/src/underworld3/cython/petsc_generic_snes_solvers.pyx +++ b/src/underworld3/cython/petsc_generic_snes_solvers.pyx @@ -53,6 +53,16 @@ expression = lambda *x, **X: public_expression(*x, _unique_name_generation=True, from underworld3.function.expressions import unwrap_expression as _unwrap_expression +def _public_names(cls): + """The names ``uw.systems`` exports a solver class under.""" + try: + systems = uw.systems + except AttributeError: + return [] + return sorted(name for name, obj in vars(systems).items() + if obj is cls and not name.startswith("SNES_")) + + def _jacobian_unwrap(expr): """Expand UWexpressions down to (but NOT including) constant atoms, for use as the input to a Jacobian derivative (``derive_by_array`` / ``diff``). @@ -1474,6 +1484,59 @@ class SolverBaseClass(uw_object): }) return terms + @classmethod + def describe_class(cls, depth=4): + """The family: the equation it solves as the residual templates + declared on the class, the terms it is given, the conditions it + accepts, and its documentation — with no instance and no mesh.""" + import inspect + from underworld3.utilities.describe import record + from underworld3.utilities._api_tools import Template + + doc = (cls.__doc__ or "").strip() + facts = {} + public = _public_names(cls) + if public: + facts["public name"] = public[0] if len(public) == 1 else public + for base, what in (("SNES_Stokes_SaddlePt", "velocity and pressure, a saddle point"), + ("SNES_MultiComponent", "several components"), + ("SNES_Vector", "a vector field"), ("SNES_Scalar", "a scalar field")): + if any(b.__name__ == base for b in cls.__mro__): + facts["unknown"] = what + break + try: + params = inspect.signature(cls.__init__).parameters + facts["time dependent"] = any(p in params for p in ("DuDt", "DFDt", "order")) + except (TypeError, ValueError): + pass + forms = {} + for name in ("F0", "F1", "PF0"): + declared = None + for base in cls.__mro__: + if name in base.__dict__: + declared = base.__dict__[name] + break + if declared is None: + continue + if isinstance(declared, Template): + forms[name] = {"symbol": declared.name, "latex": None, "text": None, + "description": (declared.description or "").strip().split("\n")[0], "where": []} + elif isinstance(declared, property): + forms[name] = {"symbol": name, "latex": None, "text": None, + "description": (declared.__doc__ or "").strip().split("\n")[0], "where": []} + terms = [{"name": attr, "symbol": None, "latex": None, "text": None, "units": None, + "description": what, "where": []} + for attr, what in (getattr(cls, "_solver_terms", None) or ())] + conditions = [] + for method in sorted(m for m in dir(cls) if m.startswith("add_") and m.endswith("_bc")): + fn = getattr(cls, method, None) + conditions.append({"mechanism": method, "type": method[4:-3].replace("_", " "), + "boundary": "any", "latex": None, + "text": (getattr(fn, "__doc__", "") or "").strip().split("\n")[0] or None}) + return record("solver_family", cls.__name__, doc.split("\n")[0], documentation=doc or None, + facts=facts, forms=forms or None, terms=terms or None, + conditions=conditions or None, terms_declared=bool(terms)) + def describe(self, depth=4): """What this solver solves, as data. diff --git a/src/underworld3/mcp/__init__.py b/src/underworld3/mcp/__init__.py index 9194009c3..c15a5726a 100644 --- a/src/underworld3/mcp/__init__.py +++ b/src/underworld3/mcp/__init__.py @@ -301,5 +301,77 @@ def uw_describe_render(record_yaml: str, format: str = "markdown", depth: int = return f"error: {exc}" +def _families(): + """Every solver, constitutive model and history family, by public name.""" + import inspect + import underworld3 as uw + from underworld3.cython.generic_solvers import SolverBaseClass + out = {"solvers": {}, "constitutive_models": {}, "histories": {}} + for name, obj in vars(uw.systems).items(): + if inspect.isclass(obj) and issubclass(obj, SolverBaseClass) and not name.startswith("SNES_"): + out["solvers"][name] = obj + for name, obj in vars(uw.constitutive_models).items(): + if (inspect.isclass(obj) and issubclass(obj, uw.constitutive_models.Constitutive_Model) + and obj is not uw.constitutive_models.Constitutive_Model): + out["constitutive_models"][name] = obj + for name, obj in vars(uw.systems.ddt).items(): + if inspect.isclass(obj) and issubclass(obj, uw.systems.ddt._DDtBase) and not name.startswith("_"): + out["histories"][name] = obj + return out + + +@server.tool(name="uw_capabilities", annotations=_READ_ONLY) +def uw_capabilities(kind: str = "all") -> str: + """What Underworld3 can solve: every solver family with the residual + templates it declares, the terms it is given and the conditions it + accepts; every constitutive model with its parameters; every transport + history scheme. kind is all, solvers, constitutive_models or + histories. One line of documentation each; uw_capability gives the + whole of one.""" + families = _families() + if kind != "all" and kind not in families: + return f"error: kind must be one of all, {', '.join(families)}" + out = {} + for group, members in families.items(): + if kind not in ("all", group): + continue + rows = [] + for name, cls in sorted(members.items()): + d = cls.describe_class() + row = {"name": name, "class": cls.__name__, "summary": d.get("summary")} + if d.get("facts"): + row.update({k: v for k, v in d["facts"].items() if k != "public name"}) + if d.get("forms"): + row["equation"] = {k: f"{v.get('symbol')}: {v.get('description')}" for k, v in d["forms"].items()} + if d.get("terms"): + row["given"] = [t["name"] for t in d["terms"]] + if d.get("conditions"): + row["conditions"] = [c["mechanism"] for c in d["conditions"]] + rows.append(row) + out[group] = rows + return _yaml(out) + + +@server.tool(name="uw_capability", annotations=_READ_ONLY) +def uw_capability(name: str, format: str = "markdown") -> str: + """One family in full: its documentation, equation templates, terms, + parameters and conditions, rendered as markdown, text or yaml. name is + a public name from uw_capabilities, such as Stokes, AdvDiffusion, + ViscoPlasticFlowModel or SemiLagrangian.""" + for group, members in _families().items(): + cls = members.get(name) + if cls is None: + cls = next((c for c in members.values() if c.__name__ == name), None) + if cls is not None: + d = cls.describe_class() + if format == "yaml": + return _yaml(d) + try: + return render(d, format) + except ValueError as exc: + return f"error: {exc}" + return f"error: no family named {name!r}; uw_capabilities lists them" + + def main(): server.run(transport="stdio") diff --git a/src/underworld3/systems/ddt.py b/src/underworld3/systems/ddt.py index 2c63c33a7..fa6645ca6 100644 --- a/src/underworld3/systems/ddt.py +++ b/src/underworld3/systems/ddt.py @@ -557,6 +557,23 @@ class _DDtBase(uw_object): Symbolic, Eulerian, SemiLagrangian). """ + @classmethod + def describe_class(cls, depth=4): + """The scheme: its documentation and the arguments that set its + order and weighting — with no instance.""" + import inspect + from underworld3.utilities.describe import record + doc = (cls.__doc__ or "").strip() + facts = {"scheme": cls.__name__} + try: + params = inspect.signature(cls.__init__).parameters + for key in ("order", "theta"): + if key in params and params[key].default is not inspect.Parameter.empty: + facts[f"default {key}"] = params[key].default + except (TypeError, ValueError): + pass + return record("history_family", cls.__name__, doc.split("\n")[0], documentation=doc or None, facts=facts) + def describe(self, depth=4): """What this history is, as data: the scheme (the class), its order, its weighting, the field it tracks and the history slots it keeps. diff --git a/src/underworld3/utilities/_api_tools.py b/src/underworld3/utilities/_api_tools.py index 190579b07..3b3b79f33 100644 --- a/src/underworld3/utilities/_api_tools.py +++ b/src/underworld3/utilities/_api_tools.py @@ -569,20 +569,16 @@ def view(self_or_cls, format=None, depth=None, class_documentation=False): ``class_documentation=True``, the class documentation is shown too. """ import inspect - from .docstring_utils import render_docstring, in_jupyter + from .docstring_utils import in_jupyter + from .describe import view as _view if inspect.isclass(self_or_cls) or class_documentation: - rendered = render_docstring(self_or_cls.__doc__, target="auto") - if in_jupyter(): - from IPython.display import Markdown, display - display(Markdown(rendered)) - if class_documentation: - display(Markdown("---")) - else: - print(rendered) - if class_documentation: - print("---") - if inspect.isclass(self_or_cls): - return + # the family: what this class solves, is given, and accepts, + # with its documentation — the same record for a class and for + # class_documentation=True on an instance + cls = self_or_cls if inspect.isclass(self_or_cls) else type(self_or_cls) + _view(cls.describe_class(), format=format, depth=depth) + if inspect.isclass(self_or_cls): + return if type(self_or_cls).describe is not uw_object.describe: from .describe import view as _view _view(self_or_cls, format=format, depth=depth) @@ -607,6 +603,20 @@ def describe(self, depth=4): doc = (type(self).__doc__ or "").strip().split("\n")[0] return record(type(self).__name__.lower(), getattr(self, "name", None), doc) + @classmethod + def describe_class(cls, depth=4): + """What this KIND of object is, as data, without an instance: the + class, the first line of its documentation as the summary, and the + documentation itself. A solver family adds the equation it solves, + the terms it is given and the conditions it accepts; a constitutive + model its parameters. ``view()`` on a class renders this, and the + capabilities catalogue is built from it. + """ + from .describe import record + doc = (cls.__doc__ or "").strip() + return record(f"{cls.__name__.lower()}_class", cls.__name__, doc.split("\n")[0], + documentation=doc or None) + # placeholder def _object_viewer(self): from IPython.display import Latex, Markdown, display diff --git a/src/underworld3/utilities/describe.py b/src/underworld3/utilities/describe.py index 7ccbb102d..5b8429e8d 100644 --- a/src/underworld3/utilities/describe.py +++ b/src/underworld3/utilities/describe.py @@ -172,6 +172,13 @@ def _render_lines(d, mode, level, depth): elif d.get("terms_declared") is False: out.append("") out.append(_para(_emph("this solver does not declare the terms it was given", mode), mode, level)) + # a class's documentation, after what it solves and accepts, without + # the first line the summary already gave + documentation = d.get("documentation") or "" + body = documentation.strip().split("\n", 1)[1] if "\n" in documentation.strip() else "" + if body.strip(): + out.append("") + out.extend(_documentation_lines(body, mode, level)) children = d.get("children") or [] if children and (depth is None or level < depth): for child in children: @@ -180,6 +187,20 @@ def _render_lines(d, mode, level, depth): return out +def _documentation_lines(text, mode, level): + """A class's documentation in the mode's markup: the docstring + renderer's Markdown for a notebook, its terminal text otherwise.""" + from underworld3.utilities.docstring_utils import render_docstring + try: + rendered = render_docstring(text, target="jupyter" if mode == "markdown" else "terminal") + except Exception: + rendered = str(text) + if mode == "latex": + rendered = _tex_text(rendered) + lines = rendered.rstrip().splitlines() + return [(" " * level + l) if mode == "text" else l for l in lines] + + def _title(d): kind = str(d.get("kind") or "object").replace("_", " ") name = d.get("name") @@ -253,13 +274,15 @@ def _form_lines(name, form, mode, level): symbol = form.get("symbol") or name what = form.get("description") or "" if mode == "text": - text = form.get("text", "") - out.append(" " * level + f" {name}: {_plain_math(text)}") + text = form.get("text") + line = f" {name}: {_plain_math(text)}" if text else f" {name} = {_plain_math(symbol)}" + out.append(" " * level + line) if what: out.append(" " * level + f" {what}") else: out.append("") - out.append(_math(f"{symbol} = {form.get('latex', '')}", mode, display=True)) + latex = form.get("latex") + out.append(_math(f"{symbol} = {latex}" if latex else str(symbol), mode, display=True)) if what: out.append(_emph(what, mode)) where = _where_lines(form.get("where", []), mode, level + 1) diff --git a/tests/test_0017_describe_and_render.py b/tests/test_0017_describe_and_render.py index d4ffc6340..3091c567a 100644 --- a/tests/test_0017_describe_and_render.py +++ b/tests/test_0017_describe_and_render.py @@ -124,3 +124,38 @@ def test_the_transcript_part_record_is_still_a_part(tmp_path, objects): records = [json.loads(l) for l in (tmp_path / "run.jsonl").read_text().splitlines()] parts = [r for r in records if r.get("kind") == "part"] assert parts and "children" not in parts[0] and parts[0]["forms"] + + +def _solver_classes(): + from underworld3.cython.generic_solvers import SolverBaseClass + found = {} + + def walk(cls): + for sub in cls.__subclasses__(): + found[sub.__name__] = sub + walk(sub) + walk(SolverBaseClass) + return found + + +def test_every_family_describes_itself_at_the_class_level(capsys): + """The class carries what the family solves, is given and accepts, with + no instance: what the capabilities catalogue is built from.""" + for name, cls in _solver_classes().items(): + d = cls.describe_class() + assert d["kind"] == "solver_family" and d["name"] == name and d["summary"], name + assert d.get("conditions"), f"{name} declares no boundary-condition methods" + for fmt in FORMATS: + assert render(d, fmt).strip() + stokes = uw.systems.Stokes.describe_class() + assert set(stokes["forms"]) == {"F0", "F1", "PF0"} and stokes["facts"]["public name"] == "Stokes" + assert {t["name"] for t in stokes["terms"]} >= {"bodyforce", "penalty"} + uw.systems.Stokes.view() + out = capsys.readouterr().out + assert "solver family SNES_Stokes" in out and "Boundary conditions" in out and "F1" in out + for cls in (uw.constitutive_models.ViscoPlasticFlowModel, uw.constitutive_models.DiffusionModel): + d = cls.describe_class() + assert d["kind"] == "constitutive_model_family" and {t["name"] for t in d["terms"]} + assert "yield_stress" in {t["name"] for t in uw.constitutive_models.ViscoPlasticFlowModel.describe_class()["terms"]} + d = uw.systems.ddt.SemiLagrangian.describe_class() + assert d["kind"] == "history_family" and d["facts"]["scheme"] == "SemiLagrangian" diff --git a/tests/test_0019_mcp_server.py b/tests/test_0019_mcp_server.py index 06f952146..b633135e2 100644 --- a/tests/test_0019_mcp_server.py +++ b/tests/test_0019_mcp_server.py @@ -96,3 +96,17 @@ def test_the_tools_answer_as_yaml(run_path): assert "Poisson" in uwmcp.uw_transcript_key(p, format="text") rendered = uwmcp.uw_describe_render(uwmcp.uw_transcript_summary(p), format="markdown") assert rendered.startswith("## transcript") + + +def test_the_capabilities_catalogue_comes_from_the_classes(): + cat = yaml.safe_load(uwmcp.uw_capabilities()) + assert set(cat) == {"solvers", "constitutive_models", "histories"} + stokes = next(r for r in cat["solvers"] if r["name"] == "Stokes") + assert set(stokes["equation"]) == {"F0", "F1", "PF0"} and "add_essential_bc" in stokes["conditions"] + assert any(r["name"] == "ViscoPlasticFlowModel" for r in cat["constitutive_models"]) + assert any(r["name"] == "SemiLagrangian" for r in cat["histories"]) + assert "error" in uwmcp.uw_capabilities(kind="nothing") + full = uwmcp.uw_capability("Stokes") + assert full.startswith("## solver family SNES_Stokes") and "Boundary conditions" in full + assert yaml.safe_load(uwmcp.uw_capability("SemiLagrangian", format="yaml"))["kind"] == "history_family" + assert "error" in uwmcp.uw_capability("Nothing") From 3407d4794e0f3ee6b3b2fb2c703db0561668a869 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sun, 27 Sep 2026 10:09:26 -0700 Subject: [PATCH 5/9] uw.capabilities(): the catalogue of families in the library, one record for notebooks and the server The class-level description exists for discovery, so the catalogue built from it belongs in the library rather than in the MCP server. uw.capabilities(kind, detail) gathers every solver, constitutive model and history family into one description record, a child per group and a child per family, one line each or the whole class record; uw.view renders any record, so a notebook reads the same catalogue a tool does. The server's uw_capabilities and uw_capability are now consumers of it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V --- .../developer/subsystems/describe-and-view.md | 16 +++- src/underworld3/__init__.py | 3 +- src/underworld3/mcp/__init__.py | 73 ++++---------- src/underworld3/utilities/capabilities.py | 95 +++++++++++++++++++ tests/test_0017_describe_and_render.py | 15 +++ tests/test_0019_mcp_server.py | 11 ++- 6 files changed, 148 insertions(+), 65 deletions(-) create mode 100644 src/underworld3/utilities/capabilities.py diff --git a/docs/developer/subsystems/describe-and-view.md b/docs/developer/subsystems/describe-and-view.md index e21bd4ce6..cc3ef6590 100644 --- a/docs/developer/subsystems/describe-and-view.md +++ b/docs/developer/subsystems/describe-and-view.md @@ -77,9 +77,19 @@ uw.systems.ddt.SemiLagrangian.describe_class() # the scheme and the `add_*_bc` methods, the parameter descriptors of a constitutive model's `_Parameters`, and the docstring, which becomes `documentation`. `view()` on a class renders it, and `view(class_documentation=True)` on an -instance renders the family before the instance. This is what the -capabilities catalogue on the MCP server is built from, so "can Underworld -solve this" is answered from the classes and cannot drift from them. +instance renders the family before the instance. + +The catalogue of every family is one record, built from those: + +```python +uw.view(uw.capabilities()) # every family, one line each +uw.view(uw.capabilities("solvers", detail="full"), depth=2) +uw.capabilities("constitutive_models") # the record, for a query +``` + +The MCP server serves the same record, so "can Underworld solve this" is +answered from the classes, in a notebook or from a tool, and cannot drift +from them. ## Adding a description to a class diff --git a/src/underworld3/__init__.py b/src/underworld3/__init__.py index 689699323..ab31c3a5e 100644 --- a/src/underworld3/__init__.py +++ b/src/underworld3/__init__.py @@ -222,7 +222,8 @@ def view(): ThermalConvectionConfig, create_thermal_convection_model, ) -from .utilities.describe import render +from .utilities.describe import render, view +from .utilities.capabilities import capabilities from .utilities.transcript_query import Transcript from .utilities.transcript_report import ( transcript_diagram, diff --git a/src/underworld3/mcp/__init__.py b/src/underworld3/mcp/__init__.py index c15a5726a..6832b7789 100644 --- a/src/underworld3/mcp/__init__.py +++ b/src/underworld3/mcp/__init__.py @@ -301,55 +301,19 @@ def uw_describe_render(record_yaml: str, format: str = "markdown", depth: int = return f"error: {exc}" -def _families(): - """Every solver, constitutive model and history family, by public name.""" - import inspect - import underworld3 as uw - from underworld3.cython.generic_solvers import SolverBaseClass - out = {"solvers": {}, "constitutive_models": {}, "histories": {}} - for name, obj in vars(uw.systems).items(): - if inspect.isclass(obj) and issubclass(obj, SolverBaseClass) and not name.startswith("SNES_"): - out["solvers"][name] = obj - for name, obj in vars(uw.constitutive_models).items(): - if (inspect.isclass(obj) and issubclass(obj, uw.constitutive_models.Constitutive_Model) - and obj is not uw.constitutive_models.Constitutive_Model): - out["constitutive_models"][name] = obj - for name, obj in vars(uw.systems.ddt).items(): - if inspect.isclass(obj) and issubclass(obj, uw.systems.ddt._DDtBase) and not name.startswith("_"): - out["histories"][name] = obj - return out - - @server.tool(name="uw_capabilities", annotations=_READ_ONLY) def uw_capabilities(kind: str = "all") -> str: """What Underworld3 can solve: every solver family with the residual templates it declares, the terms it is given and the conditions it accepts; every constitutive model with its parameters; every transport history scheme. kind is all, solvers, constitutive_models or - histories. One line of documentation each; uw_capability gives the - whole of one.""" - families = _families() - if kind != "all" and kind not in families: - return f"error: kind must be one of all, {', '.join(families)}" - out = {} - for group, members in families.items(): - if kind not in ("all", group): - continue - rows = [] - for name, cls in sorted(members.items()): - d = cls.describe_class() - row = {"name": name, "class": cls.__name__, "summary": d.get("summary")} - if d.get("facts"): - row.update({k: v for k, v in d["facts"].items() if k != "public name"}) - if d.get("forms"): - row["equation"] = {k: f"{v.get('symbol')}: {v.get('description')}" for k, v in d["forms"].items()} - if d.get("terms"): - row["given"] = [t["name"] for t in d["terms"]] - if d.get("conditions"): - row["conditions"] = [c["mechanism"] for c in d["conditions"]] - rows.append(row) - out[group] = rows - return _yaml(out) + histories. One line each; uw_capability gives the whole of one. The + same catalogue a notebook gets from uw.capabilities().""" + from ..utilities.capabilities import capabilities + try: + return _yaml(capabilities(kind)) + except ValueError as exc: + return f"error: {exc}" @server.tool(name="uw_capability", annotations=_READ_ONLY) @@ -358,19 +322,16 @@ def uw_capability(name: str, format: str = "markdown") -> str: parameters and conditions, rendered as markdown, text or yaml. name is a public name from uw_capabilities, such as Stokes, AdvDiffusion, ViscoPlasticFlowModel or SemiLagrangian.""" - for group, members in _families().items(): - cls = members.get(name) - if cls is None: - cls = next((c for c in members.values() if c.__name__ == name), None) - if cls is not None: - d = cls.describe_class() - if format == "yaml": - return _yaml(d) - try: - return render(d, format) - except ValueError as exc: - return f"error: {exc}" - return f"error: no family named {name!r}; uw_capabilities lists them" + from ..utilities.capabilities import family + d = family(name) + if d is None: + return f"error: no family named {name!r}; uw_capabilities lists them" + if format == "yaml": + return _yaml(d) + try: + return render(d, format) + except ValueError as exc: + return f"error: {exc}" def main(): diff --git a/src/underworld3/utilities/capabilities.py b/src/underworld3/utilities/capabilities.py new file mode 100644 index 000000000..2ef7a5d49 --- /dev/null +++ b/src/underworld3/utilities/capabilities.py @@ -0,0 +1,95 @@ +r"""What Underworld3 can solve, discovered from the classes. + +Every solver, constitutive model and history family describes itself at +the class level (``describe_class()``): the equation it declares, the terms +it is given, the conditions it accepts, its parameters, its documentation. +:func:`capabilities` gathers those into one record, so a notebook, a note +and the MCP server read the same catalogue, and none of it is written by +hand. + + uw.view(uw.capabilities()) # every family, one line each + uw.view(uw.capabilities("solvers", detail="full"), depth=2) + uw.capabilities("constitutive_models")["children"][0]["children"] +""" + +import inspect + +from .describe import record + +GROUPS = ("solvers", "constitutive_models", "histories") + + +def families(): + """Every solver, constitutive model and history family the package + exports, by public name: ``{"solvers": {...}, "constitutive_models": + {...}, "histories": {...}}``.""" + import underworld3 as uw + from underworld3.cython.generic_solvers import SolverBaseClass + + out = {group: {} for group in GROUPS} + for name, obj in vars(uw.systems).items(): + if inspect.isclass(obj) and issubclass(obj, SolverBaseClass) and not name.startswith("SNES_"): + out["solvers"][name] = obj + base = uw.constitutive_models.Constitutive_Model + for name, obj in vars(uw.constitutive_models).items(): + if inspect.isclass(obj) and issubclass(obj, base) and obj is not base: + out["constitutive_models"][name] = obj + for name, obj in vars(uw.systems.ddt).items(): + if inspect.isclass(obj) and issubclass(obj, uw.systems.ddt._DDtBase) and not name.startswith("_"): + out["histories"][name] = obj + return out + + +def _summary_row(name, description): + """A family reduced to what a catalogue line needs.""" + facts = dict(description.get("facts") or {}) + facts.pop("public name", None) + if description.get("forms"): + facts["equation"] = ", ".join(f"{k}: {v.get('description') or v.get('symbol')}" + for k, v in description["forms"].items()) + if description.get("terms"): + facts["given"] = [t["name"] for t in description["terms"]] + if description.get("conditions"): + facts["conditions"] = [c.get("mechanism") for c in description["conditions"]] + return record(description.get("kind", "family"), name, description.get("summary", ""), + facts={"class": description.get("name"), **facts}) + + +def capabilities(kind="all", detail="summary"): + """The catalogue as a description record: a child per group, and a + child per family under it. ``kind`` is ``"all"`` or one of + ``"solvers"``, ``"constitutive_models"``, ``"histories"``. With + ``detail="summary"`` each family is one line with its equation names, + terms and conditions; with ``"full"`` each is its whole + ``describe_class()`` record, documentation included.""" + found = families() + if kind != "all" and kind not in found: + raise ValueError(f"kind must be 'all' or one of {GROUPS}, not {kind!r}") + if detail not in ("summary", "full"): + raise ValueError("detail must be 'summary' or 'full'") + groups = [] + for group in GROUPS: + if kind not in ("all", group): + continue + members = [] + for name, cls in sorted(found[group].items()): + description = cls.describe_class() + members.append(description if detail == "full" else _summary_row(name, description)) + groups.append(record(group, None, f"{len(members)} {group.replace('_', ' ')}", children=members)) + total = sum(len(g["children"]) for g in groups) + return record("capabilities", "underworld3", + f"{total} families: " + ", ".join(f"{len(g['children'])} {g['kind'].replace('_', ' ')}" + for g in groups), + children=groups) + + +def family(name): + """One family's full class-level description, by public name or class + name, or ``None``.""" + for members in families().values(): + cls = members.get(name) + if cls is None: + cls = next((c for c in members.values() if c.__name__ == name), None) + if cls is not None: + return cls.describe_class() + return None diff --git a/tests/test_0017_describe_and_render.py b/tests/test_0017_describe_and_render.py index 3091c567a..8df51077f 100644 --- a/tests/test_0017_describe_and_render.py +++ b/tests/test_0017_describe_and_render.py @@ -159,3 +159,18 @@ def test_every_family_describes_itself_at_the_class_level(capsys): assert "yield_stress" in {t["name"] for t in uw.constitutive_models.ViscoPlasticFlowModel.describe_class()["terms"]} d = uw.systems.ddt.SemiLagrangian.describe_class() assert d["kind"] == "history_family" and d["facts"]["scheme"] == "SemiLagrangian" + + +def test_the_capabilities_catalogue_is_the_families_in_one_record(capsys): + cat = uw.capabilities() + assert cat["kind"] == "capabilities" and [g["kind"] for g in cat["children"]] == [ + "solvers", "constitutive_models", "histories"] + solvers = {c["name"]: c for c in cat["children"][0]["children"]} + assert "Stokes" in solvers and solvers["Stokes"]["facts"]["class"] == "SNES_Stokes" + assert "given" in solvers["Stokes"]["facts"] and "conditions" in solvers["Stokes"]["facts"] + full = uw.capabilities("solvers", detail="full") + assert full["children"][0]["children"][0].get("documentation") + uw.view(uw.capabilities("histories")) + assert "SemiLagrangian" in capsys.readouterr().out + with pytest.raises(ValueError): + uw.capabilities("nothing") diff --git a/tests/test_0019_mcp_server.py b/tests/test_0019_mcp_server.py index b633135e2..5b23feef9 100644 --- a/tests/test_0019_mcp_server.py +++ b/tests/test_0019_mcp_server.py @@ -100,11 +100,12 @@ def test_the_tools_answer_as_yaml(run_path): def test_the_capabilities_catalogue_comes_from_the_classes(): cat = yaml.safe_load(uwmcp.uw_capabilities()) - assert set(cat) == {"solvers", "constitutive_models", "histories"} - stokes = next(r for r in cat["solvers"] if r["name"] == "Stokes") - assert set(stokes["equation"]) == {"F0", "F1", "PF0"} and "add_essential_bc" in stokes["conditions"] - assert any(r["name"] == "ViscoPlasticFlowModel" for r in cat["constitutive_models"]) - assert any(r["name"] == "SemiLagrangian" for r in cat["histories"]) + groups = {g["kind"]: g["children"] for g in cat["children"]} + assert set(groups) == {"solvers", "constitutive_models", "histories"} + stokes = next(r for r in groups["solvers"] if r["name"] == "Stokes") + assert "F0" in stokes["facts"]["equation"] and "add_essential_bc" in stokes["facts"]["conditions"] + assert any(r["name"] == "ViscoPlasticFlowModel" for r in groups["constitutive_models"]) + assert any(r["name"] == "SemiLagrangian" for r in groups["histories"]) assert "error" in uwmcp.uw_capabilities(kind="nothing") full = uwmcp.uw_capability("Stokes") assert full.startswith("## solver family SNES_Stokes") and "Boundary conditions" in full From 28a802780bc45e92bdc82f783bffbf6635090f19 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sun, 27 Sep 2026 10:52:17 -0700 Subject: [PATCH 6/9] Capability guides live in docs; skills are symlinks; a family names its guides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Curated guidance the code cannot state about itself — which transport scheme, which boundary treatment, how to make a hard solve converge — was split between the AI skills in .claude/skills and the rulings in CLAUDE.md, two paths with no index and no reader outside an assistant. Each guide is now one MyST page under docs/developer/guides with front matter naming the families it applies to; the seven skills are symlinks to those pages, so a skill cannot drift from its guide. Two guides are new: transport schemes, drafted from what the tests established this month with the rulings still open marked as such, and the boundary-condition rulings, gathered from CLAUDE.md and the issues. uw.capabilities() reads the front matter and lists a guide beside its family; the class-level view does the same, so uw.systems.Stokes.view() ends with the guides that apply to Stokes; the server serves them with uw_guides and uw_guide. The developer index carries the table. The adversarial review contract gains the rule, and CLAUDE.md the pointer: a change to a family is reviewed against every guide that names it, in the same change. test_0030 fails on a copied skill, a guide without front matter, or a family no class carries. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V --- .claude/skills/adapt-on-top-faults/SKILL.md | 370 +--------------- .claude/skills/adaptive-meshing/SKILL.md | 406 +---------------- .claude/skills/cetz-figures/SKILL.md | 144 +------ .../skills/free-surface-convection/SKILL.md | 225 +--------- .claude/skills/nonlinear-solver/SKILL.md | 322 +------------- .claude/skills/plasticity-solvers/SKILL.md | 227 +--------- .claude/skills/uw-visualisation/SKILL.md | 126 +----- CLAUDE.md | 8 + docs/developer/guides/adapt-on-top-faults.md | 371 ++++++++++++++++ docs/developer/guides/adaptive-meshing.md | 407 ++++++++++++++++++ docs/developer/guides/adversarial-review.md | 10 + .../guides/boundary-condition-rulings.md | 89 ++++ docs/developer/guides/cetz-figures.md | 145 +++++++ .../guides/free-surface-convection.md | 226 ++++++++++ docs/developer/guides/nonlinear-solver.md | 323 ++++++++++++++ docs/developer/guides/plasticity-solvers.md | 228 ++++++++++ docs/developer/guides/transport-schemes.md | 77 ++++ docs/developer/guides/uw-visualisation.md | 127 ++++++ docs/developer/index.md | 29 ++ src/underworld3/constitutive_models.py | 10 +- .../cython/petsc_generic_snes_solvers.pyx | 7 + src/underworld3/mcp/__init__.py | 25 ++ src/underworld3/systems/ddt.py | 7 + src/underworld3/utilities/capabilities.py | 79 +++- tests/test_0030_capability_guides.py | 62 +++ 25 files changed, 2235 insertions(+), 1815 deletions(-) mode change 100644 => 120000 .claude/skills/adapt-on-top-faults/SKILL.md mode change 100644 => 120000 .claude/skills/adaptive-meshing/SKILL.md mode change 100644 => 120000 .claude/skills/cetz-figures/SKILL.md mode change 100644 => 120000 .claude/skills/free-surface-convection/SKILL.md mode change 100644 => 120000 .claude/skills/nonlinear-solver/SKILL.md mode change 100644 => 120000 .claude/skills/plasticity-solvers/SKILL.md mode change 100644 => 120000 .claude/skills/uw-visualisation/SKILL.md create mode 100644 docs/developer/guides/adapt-on-top-faults.md create mode 100644 docs/developer/guides/adaptive-meshing.md create mode 100644 docs/developer/guides/boundary-condition-rulings.md create mode 100644 docs/developer/guides/cetz-figures.md create mode 100644 docs/developer/guides/free-surface-convection.md create mode 100644 docs/developer/guides/nonlinear-solver.md create mode 100644 docs/developer/guides/plasticity-solvers.md create mode 100644 docs/developer/guides/transport-schemes.md create mode 100644 docs/developer/guides/uw-visualisation.md create mode 100644 tests/test_0030_capability_guides.py diff --git a/.claude/skills/adapt-on-top-faults/SKILL.md b/.claude/skills/adapt-on-top-faults/SKILL.md deleted file mode 100644 index 9fec8795a..000000000 --- a/.claude/skills/adapt-on-top-faults/SKILL.md +++ /dev/null @@ -1,369 +0,0 @@ ---- -name: adapt-on-top-faults -description: Recipe for Underworld3 FAULT models on an NVB adapt-on-top mesh — resolve a fault Surface by LOCAL refinement (mesh.adapt(metric, max_levels=...) returns a child; NVB is the 2D default engine), drive it from the fault's EXACT signed distance, use rotated strong free-slip (composes with transverse-isotropy where Nitsche does not), recover dynamic topography from the constraint reaction, and run advection-diffusion on the adapted mesh with field transfer across re-adaptation. Reach for THIS for instantaneous/coupled fault-flow problems. For MMPDE node-movement convection use the `adaptive-meshing` skill instead; for rendering use `uw-visualisation`. ---- - -# adapt-on-top-faults - -The validated recipe for **fault problems on a locally-refined (adapt-on-top) mesh**. -Distilled from the annulus fault study (2026-07, `feature/adapt-on-top`). - -**This is the REFINEMENT paradigm**, not the mover one: -- `mesh.adapt(metric, max_levels=...)` bisects the base finest **locally** and returns - a **new child mesh** (`child.parent is mesh`). It is *adapt / re-adapt*, NOT node - movement — non-cumulative (each call re-marks from the static base). The child owns - a custom-P geometric-MG (FMG) tail so solvers on it get multigrid for free. -- For **MMPDE / equidistribution node movement** (deforming the same mesh to a field) - use the **`adaptive-meshing`** skill instead. Different tool; don't mix them up. - -Reference implementations (copy from these — all run np1/2/4): -`~/+Simulations/nvb_parallel_fault_study/` (weak-fault Stokes + FMG), -`~/+Simulations/shear_box_fault_study/` (iso vs TI, orientation sweeps), -`~/+Simulations/annulus_fault_study/` (rotated free-slip + topography + moving fault + -advection-diffusion). Companion skills: `uw-visualisation`, `adaptive-meshing`. - ---- - -## The core loop (fault → metric → adapt → child) - -```python -import underworld3 as uw, numpy as np, sympy - -# Base mesh MUST be built with refinement>=1 (supplies the coarse MG tail that NVB -# extends). Base cellSize ~ 2x the target h_near is the SWEET SPOT (see gotchas). -base = uw.meshing.Annulus(radiusInner=0.55, radiusOuter=1.0, cellSize=0.08, - refinement=1, qdegree=3) # or UnstructuredSimplexBox(..., refinement=2) - -# fault as a Surface (polyline control points, N x 3 with z=0 in 2D) -fault = uw.meshing.Surface("fault", base, fault_pts, symbol="F") -fault.discretize() - -# METRIC = a CALLABLE built from the fault's EXACT signed distance. This is the key -# to clean, non-patchy grading: it is evaluated at each refined level's centroids, -# so it resolves itself at the new resolution (no P1-field aliasing). -metric = fault.refinement_metric_function(h_near=0.02, h_far=0.08, width=0.05, - profile="linear") -child = base.adapt(metric, max_levels=3) # -> graded child (NVB is the 2D default) -``` - -- NVB (the 2D default engine) = graded newest-vertex bisection (bounded closure, - parallel via the native `uwnvb` transform; bit-confluent serial↔parallel); - `engine=` is the advanced selector. `engine="sbr"` = uniform - patch (the default; not graded). NVB is 2D only for now. -- `max_levels` is the isotropic-equivalent depth (NVB runs `2*max_levels` bisection - passes). The metric shape decides the grading; `max_levels` just caps it. - -### Metric options (all accepted by `adapt`) -1. **callable** `metric(centroids)->M` — **preferred for faults**. Evaluated per level. -2. MeshVariable / sympy expression — sampled via `uw.function.evaluate` from the BASE - mesh → a peaked `M=1/h²` aliases → *patchy* levels. Avoid for thin features. - -**Custom refinement shape** — pass any callable. For a *fat, uniformly-fine* band -(not just a thin line at the fault), a flat-core metric: -```python -def metric(pts, _f=fault): - d = _f.unsigned_distance(pts) # EXACT distance at arbitrary points - core, ramp, hn, hf = 0.02, 0.05, 0.01, 0.08 - h = np.where(d < core, hn, np.minimum(hn + (hf-hn)*(d-core)/ramp, hf)) - return 1.0 / h**2 -``` - -`Surface` distance API (all exact, arbitrary query points): -`fault.signed_distance(coords)`, `fault.unsigned_distance(coords)`, -`fault.director` (unit normal = normalised ∇(signed distance); the TI weak-plane -director), `fault.refinement_metric_function(...)`. - ---- - -## Engines: `nvb` vs `edge_split` — and what runs in parallel - -`engine="nvb"` (default) is graded newest-vertex bisection: bounded conforming -closure, a similarity-class bound that keeps child quality tied to the base, and -**partition-independent** output. `engine="edge_split"` splits the **longest edge** -of every cell coarser than the metric asks for and needs **no conforming closure -at all**, because splitting an edge divides every incident cell at the same new -vertex. Consequences: - -- refinement **cannot escape the marked region** — the band hugs the feature - instead of a halo around it; -- it marks on the cell **DIAMETER**, not `(dim!·vol)^(1/dim)`. The volume proxy - reported the target met while the mesh was **3.2× coarser** across the feature; -- it gives up the similarity-class bound, so quality at depth is not guaranteed - the way bisection's is — that is what `repair=` and `relax()` are for. - -**Both run in parallel, 2-D and 3-D, and both are bit-confluent** (identical mesh -at any communicator size). `edge_split` drives the same compiled `uwnvb_bisect` -transform as NVB, so it inherits star-forest propagation, co-partitioning, labels -and coordinates. Verified at np=1/2/3/4 up to 56k cells. - -```python -child = base.adapt(metric, max_levels=3, engine="edge_split") -child = base.adapt(metric, max_levels=3, engine="edge_split", repair=True) -``` - -`repair=True` runs a **reconnection (Lawson flip) pass** after each generation — -2-D and `edge_split` only; it raises rather than silently doing nothing otherwise. -It gates on **reducing the largest angle**, NOT on Delaunay: Delaunay maximises the -*minimum* angle while P1 interpolation depends on the *maximum* (Babuška–Aziz), and -flipping a gmsh mesh toward Delaunay was measured to RAISE the 99th-percentile max -angle 126.8° → 129.3°. gmsh optimises shape, not the empty-circle property. - -- **worth it on a POOR base** — anisotropic, graded, relaxed, or read from a file: - 99th-pct max angle 156° → 115°, slivers below q=0.1 3.84 % → 0.00 %. On a clean - gmsh base it moves 124.7° → 120.5° and the error not at all. -- ⚠️ **it gives up bit-confluence.** Which cavities may be flipped depends on where - the partitioner cut (no cavity may contain a cell incident on a shared point). - Conformity, orientation, volume, labels and the SF stay exact at every rank - count; only the choice of flips near a seam differs. Hence opt-in. -- Seam cost is small and **shrinks with resolution**: frozen repair sites 0.9–3.5 % - at 56k cells, np=2..8, halving with every halving of the target size. In a fault - band specifically, 5.5 % at np=2 and 13 % at np=4 on a 4k-cell mesh. -- ⚠️ the 99th-pct angle recovers under a frozen seam but the **absolute max does - not** — a few worst cells sit on the seam (148° vs 123° serial). -- it invalidates the cell-parent map for the any-degree MG transfer (a flipped - cell can straddle two coarse cells), so degree ≥ 2 falls back to the geometric - prolongation builder. The exact vertex prolongation survives — flips move no - vertex. - -## Relaxing an adapted fault mesh — PIN THE BAND - -`child.relax()` on a mesh refined onto an interface **makes things worse**. The -MMPDE mover optimises element shape against an equilateral reference and knows -nothing about where the material changes, so it slides the small cells that -refinement placed on the interface *off* it. Measured on a step-edged fault: -manufactured stress across the interface **+77 %**, and it stopped being confined -to the fault. Counter-intuitively it *reduces* the number of straddling cells -(1343 → 965) and is still worse, because the survivors are bigger. - -```python -child.relax(pin_bands=[fault]) # interface = the surface itself -child.relax(pin_bands=[(fault, 0.02)], pin_halo=2) # weak zone of half-width 0.02 -``` - -Leak unchanged to five decimal places (0.03075 → 0.03076), confinement preserved, -straddling count identical — while the mover still reshapes the rest of the -domain. `pin_halo` (default 1) pins extra rings; pinning only the cut cells lets -the mover pull on them from outside. `pin_bands` **merges** with the auto-pinned -boundaries, so it cannot silently release the domain edge. - -## Fault as a constitutive weak zone (iso and TI) - -The metric only needs the fault GEOMETRY (pure distance). The constitutive weak zone -needs the fault's distance/normal ON THE CHILD. Two ways: - -```python -# (A) re-home the SAME fault onto the child (cleanest; distance recomputes on child) -fault.remap_to(child) -eta = fault.influence_function(width=0.04, value_near=1e-3, value_far=1.0, - profile="smoothstep") # isotropic weak zone - -# (B) or build a child-side Surface (needed if the base fault is still in use for a -# base-mesh metric — symbol disambiguation refuses a base-mesh symbol in a child -# solver). fault_c = uw.meshing.Surface("fault_c", child, fault_pts); fault_c.discretize() -``` - -Isotropic weak zone: -```python -stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel -stokes.constitutive_model.Parameters.shear_viscosity_0 = eta # drops near fault -``` - -Transverse-isotropic weak PLANE (the physical fault; low fault-parallel shear): -```python -stokes.constitutive_model = uw.constitutive_models.TransverseIsotropicFlowModel -stokes.constitutive_model.Parameters.shear_viscosity_0 = eta_bulk # normal viscosity (constant) -stokes.constitutive_model.Parameters.shear_viscosity_1 = eta # weak near fault -> bulk far -stokes.constitutive_model.Parameters.director = fault.director # unit fault normal -``` -The **TI Jacobian is the full consistent tangent** (not isotropic + defect -correction — that framing is WRONG). The TI velocity FMG V-cycle needs a few more -iters than iso (~8 vs ~2) because of a directional near-null mode the isotropic -point-smoother doesn't damp — bounded and contrast-independent, not a bug. TI needs -~2 elements across the weak zone to resolve (iso ~1). - ---- - -## Rotated strong free-slip (the reason to use this, not Nitsche) - -Nitsche free-slip is INCOMPATIBLE with the TI model (its penalty scales by an -isotropic viscosity). Rotated strong free-slip imposes `u·n̂=0` as an ESSENTIAL -constraint in a per-node (n,t) frame → machine-zero leakage AND composes with TI. - -```python -# normal=None (the default) is measure-weighted and consistent with the assembly — -# prefer it. An analytic nhat is exact for the TRUE circle but keeps a consistency -# error against the faceted integral (#560); use it only when the constraint must -# follow the geometry rather than the mesh. -nhat = mesh.CoordinateSystem.unit_e_0 # exact radial normal (annulus/sphere) -stokes.add_rotated_freeslip_bc(0, "Upper", normal=nhat) -stokes.add_rotated_freeslip_bc(0, "Lower", normal=nhat) -stokes.petsc_use_pressure_nullspace = True # enclosed -> pressure gauge -stokes.solve() # rigid-rotation gauge auto-removed -# Convergence status: read stokes._rotated_freeslip_info = {ksp_reason, -# nonlinear_iterations, rotation_gauge_removed, reaction} — NOT s.snes.getConvergedReason() -# (the rotated solve is a manual loop, not snes.solve). Also sanity-check v·n leakage (~1e-16). -``` - -**Nonlinear rheology / warm-start / timestepping (PR #298, `feature/rotated-snes`):** -rotated free-slip now works *inside* the nonlinear iteration — a nonlinearity probe -auto-dispatches power-law / VEP / TI-with-yield models to a Newton/Picard loop -(`solve_rotated_freeslip_nonlinear`), so warm-started time loops are correct. On the -worktree state *before* #298 lands, the rotated path is a SINGLE linear solve — it -silently returns one Newton linearisation from `u=0` for a nonlinear model. If you -run nonlinear TI + timestepping with rotated free-slip, make sure #298 is in. - -**Dynamic topography** from the constraint reaction (the reason to bother): -```python -h = uw.discretisation.MeshVariable("h", mesh, 1, degree=1) -stokes.dynamic_topography("Upper", h, buoyancy_scale=rho_g) # h = -(σ_nn - mean)/ρg -xs, sig = stokes.boundary_normal_traction("Upper") # or the raw σ_nn (lumped-mass) -``` - ---- - -## FMG under rotated free-slip - -`rotated_bc.solve_rotated_freeslip` builds its OWN fieldsplit KSP, but it resolves -the multigrid hierarchy through the same `custom_mg.build_transfers` rule as the -standard path: an explicit `set_custom_fmg` registration wins, otherwise a -mesh-owned adapt tail is picked up opportunistically. So: -- On an `adapt()` **child**, the mesh-owned custom-P tail is auto-picked-up for the - velocity block → FMG for free, **rotated free-slip included** (the old - unreachability — rotated solves silently falling back to GAMG on adapt - children — was #467, fixed). -- On a plain **refined `Annulus`** with **rotated** free-slip, the native - `dm_hierarchy` is still not read; to get FMG you must build a coarse-mesh tail - and call: - ```python - from underworld3.utilities.custom_mg import set_custom_fmg - set_custom_fmg(stokes, [Annulus(cs=0.16), Annulus(cs=0.08)], field_id=0) # velocity block - ``` - (~4 velocity iters vs ~26 GAMG). Otherwise it falls back to GAMG (fine, just slower). - -The default velocity-block preconditioner and the pressure Schur (`1/η`) are already -near-optimal for TI — do **not** hand-roll a "TI-aware Schur"; measured, the default -`1/η₀` beats every alternative (the weak TI mode is fault-parallel shear, which is -volume-preserving, so pressure sees η₀). - ---- - -## Moving fault + re-adaptation + field transfer - -`adapt()` is non-cumulative and deterministic. Move the fault, re-adapt from the -static base, carry any field by interpolation: - -```python -for step in range(N): - fault_pts = move(fault_pts, step) # kinematics - fault = uw.meshing.Surface(f"fault{step}", base, fault_pts, symbol="F"); fault.discretize() - child = base.adapt(fault.refinement_metric_function(...), max_levels=3) - # verify: folded=0 (all cell |vol|>0), base unchanged (non-cumulative) - # carry a field child_{k-1} -> child_k by interpolation: - T = uw.discretisation.MeshVariable(f"T{step}", child, 1, degree=1) - if T_prev is None: - T.data[:,0] = uw.function.evaluate(T0_expr, T.coords) - else: - T.data[:,0] = uw.function.evaluate(T_prev.sym, T.coords) # mesh->mesh interp - T_prev = T -``` -Transfer error is **non-accumulating** for a smooth field (bounded by the per-mesh P1 -representation floor, ~0.2% on a fine base, ~3% on a coarse base) — repeated -re-meshing does not diffuse a smooth field away. Sharp fronts lose more per transfer. - ---- - -## Advection-diffusion on the adapted mesh - -```python -T = uw.discretisation.MeshVariable("T", child, 1, degree=2) -adv = uw.systems.AdvDiffusionSLCN(child, u_Field=T, V_fn=stokes.u.sym, order=1, - monotone_mode="clamp") # clamp = bounded, no overshoot -adv.constitutive_model = uw.constitutive_models.DiffusionModel -adv.constitutive_model.Parameters.diffusivity = 2e-4 -adv.f = 0.0 -dt = 0.015 # FIXED dt — SLCN is semi-Lagrangian (unconditionally - # stable). estimate_dt() reports the tiny fault-band - # Courant limit; do NOT use it to size the step. -for step in range(nsteps): - adv.solve(timestep=dt) -``` -- **SLCN dt is NOT Courant-limited** by the fine fault-band cells — pick dt from the - coarse-region advection, verify accuracy. -- The scalar AD auto-FMG-injection bug (velocity-block custom-P mismatching a scalar - operator on adapt children → PtAP error 60) is **FIXED**: `auto_inject_custom_mg` - now calls `snes.setUp()` before reading the finest reduced map (so the DM section - is the finalized space the operator lives on) and validates that map against the - assembled operator. Custom-P installs successfully on scalar SLCN AdvDiffusion on - NVB adapt children (no skip-guard, no workaround) — `bugfix/custom-mg-parallel`. - ---- - -## Sizing the band, and how the fault margin is represented - -The artefact that matters for a fault is **stress manufactured by elements that -straddle the weak-zone margin** — high strain rate at one end, high viscosity at -the other. It is exactly - -```python -leak = 2 * (eta.mean(axis=1) * edot.mean(axis=1) - (eta * edot).mean(axis=1)) -``` - -per cell (vertex values), i.e. `−2 Cov(η, ε̇)`: **zero** for any cell wholly inside -or wholly outside the weak zone, positive only across the transition. It lives -strictly *inside* elements — plotting nodal `2ηε̇` cannot show it, because at a node -the two fields are sampled at the same point and are consistent by construction. - -Measured guidance, all at matched cell count: - -- **Band width.** The answer depends on what you are minimising, and the two - objectives disagree. *Total* leak: narrower is better (concentrate cells where - ∇η is steepest). Leak **into the matrix** (what usually matters): an optimum at - core half-width ≈ the **influence width**, 2.6× better than a narrow band. - Straddling-cell count: wider is monotonically better. -- **Don't invent a marking rule.** Marking on within-cell η variation is - intuitive and measurably *worse* per DOF than the plain distance size field: - N^-0.37 (absolute jump) or a complete stall (log ratio) against **N^-1.04**. The - leak is spread across the whole transition, not concentrated in a few cells, so - there is nothing for a targeting rule to target. ⚠️ The log ratio is largest - where η is *smallest* — it refines the fault core, the opposite end from the - problem. -- **A step-edged margin confines it.** `influence_function(profile="step")` (the - DEFAULT profile) plus marking on the distance level set puts essentially **0 %** - of the leak beyond d=0.03, against 11.4 % for a smooth blend, and converges - slightly faster (N^-1.32). The price: total leak 2.5× higher and the **worst - single cell 20× worse** (21.4 vs 1.04) — concentrated into a one-cell collar - welded to the interface rather than spread. For a viscous solve that is a clear - win; for a yielding model the worst cell is what reaches yield first, so weigh - it. Mark geometrically on the level set: once the edge is sharp, sampled η - depends on which side a vertex happens to fall. -- **Exact fixes.** An element-wise constant (P0) viscosity makes `Cov(η, ε̇) ≡ 0` - on any mesh — not reduced, zero. So does aligning the interface with element - boundaries — and there are now primitives that do exactly that: `place_sheet` / - `place_thin_volume` / `remove_embedded` in `utilities/place_surface.py` - (#517–#526). Both fixes move the error from *inside* elements to *where the - element boundaries fall*, which makes `relax(pin_bands=...)` the lever rather - than shape repair. ⚠️ P0 also breaks any within-cell marking rule (contrast is - identically zero) — it would have to be reposed on the facet jump. - -## Gotchas / rough edges (candidates to fix as we go) - -| symptom / edge | cause & handling | -|---|---| -| patchy along-fault refinement (level 4 here, 2 there) | P1-interpolated `M=1/h²` aliasing. Use the **callable exact-distance** metric. | -| child solver rejects `fault.distance.sym` (foreign-mesh error) | symbol disambiguation. `fault.remap_to(child)` or build a child-side `Surface`. **Rough edge**: needing two Surface objects. | -| adapt added-cell count jitters ±30% step-to-step | small-number geometry on a thin band. Deterministic; quality (near-fault h) is constant. Base ≈ **2× target** minimises it (CoV ~10% at 0.08 base vs 19% at 0.14, 24% at 0.05). | -| tiny AD timestep / slow advection | `estimate_dt` returns the fault-band Courant limit. Use a fixed dt (SLCN is unconditionally stable). **Rough edge**: `estimate_dt` not adapt-aware. | -| surface-breaking fault: huge local vmax; topo peak keeps growing | real stress singularity at the outcrop. Topo peak **saturates** (integrable/log, bounded by finite buoyancy) — far field is fine; only the pointwise outcrop value is mesh-dependent. | -| rotated free-slip Stokes "not converged" | `s.snes` isn't the solving object (manual loop). Read `stokes._rotated_freeslip_info['ksp_reason']` / `['nonlinear_iterations']` (PR #298); sanity-check v·n leakage. | -| nonlinear TI/VEP + rotated free-slip + timestepping gives a wrong (frozen) answer | pre-#298 the rotated path is ONE linear solve (one Newton step from u=0). PR #298 runs it inside a Newton/Picard loop — ensure it's merged for nonlinear/warm-start runs. | -| FMG under rotated free-slip on a plain (non-adapt) refined mesh | the rotated KSP resolves hierarchies via `custom_mg.build_transfers`: an adapt child's mesh-owned tail is picked up AUTOMATICALLY (#467 fixed the old silent GAMG fallback), but the native `dm_hierarchy` is still not read — on a plain refined mesh, `set_custom_fmg(..., field_id=0)`. | -| NVB at np>1 raises NotImplementedError | native `_nvb_transform` extension not built (needs the custom-PETSc/amr env). Both `nvb` and `edge_split` are otherwise fully parallel, 2-D and 3-D. | -| high stress appears in the matrix beside the fault | elements STRADDLING the weak-zone margin: one end sees high strain rate, the other high viscosity. The FE forms `mean(η)·mean(ε̇)`; the honest cell average is `mean(η ε̇)`, and the difference is `−2 Cov(η, ε̇)` across the cell. Zero for any cell wholly in or wholly out. See the band-width section below. | -| scattered 1-level refinement across the WHOLE domain; far-field quality drops | the metric's far clip ``h_far`` sits below the base mesh's cell DIAMETERS (gmsh ``cellSize`` is a target edge length; diameters run 1.2–2.5x it — measured 0.108–0.223 for cellSize 0.18+ref 1). ``edge_split`` marks on diameter, so 100% of the domain refines once and the unrequested bisection DE-CONDITIONS the grid (far-field median q 0.372 -> 0.295, measured). Set ``h_far >= 1.05 * cell_diameters(base.dm).max()``. | -| refinement band narrower than the fault's INFLUENCE | measured: η still 0.07 at d=0.06 while the mesh has already coarsened 4×, so the artefact peaks on the transition flank, not on the fault. **85 % of it sits at d>0.01.** Size the flat core from the *influence* width, not the fault. | -| `uw.function.evaluate` fails "Total components 8 != 6" | cached-interpolation mismatch on a mesh already carrying several solver variables. Sample the field numerically from `surface.unsigned_distance` instead. | -| bare SIGSEGV, no traceback, after building a Mesh from a raw DM | `uw.discretisation.Mesh(dm, ...)` TAKES THE DM OVER. Read geometry from `child.dm`, never the handle you passed in. | -| `KeyError: 'Left'` from a Mesh built on a refined DM | `Mesh(dm)` without `boundaries=` loses the boundary ENUM even though the labels are on the DM. Pass `boundaries=base.boundaries`. | - -**Build/run**: this lives on the `feature/adapt-on-top` worktree; env -`.pixi/envs/amr-dev/bin/{python,mpirun}`; `./uw build` after source changes. diff --git a/.claude/skills/adapt-on-top-faults/SKILL.md b/.claude/skills/adapt-on-top-faults/SKILL.md new file mode 120000 index 000000000..e81a9ec22 --- /dev/null +++ b/.claude/skills/adapt-on-top-faults/SKILL.md @@ -0,0 +1 @@ +../../../docs/developer/guides/adapt-on-top-faults.md \ No newline at end of file diff --git a/.claude/skills/adaptive-meshing/SKILL.md b/.claude/skills/adaptive-meshing/SKILL.md deleted file mode 100644 index c2c98da26..000000000 --- a/.claude/skills/adaptive-meshing/SKILL.md +++ /dev/null @@ -1,405 +0,0 @@ ---- -name: adaptive-meshing -description: The canonical, workable recipe for Underworld3 moving-mesh / adaptive-mesh convection (annulus stagnant-lid, faults, free surface). Reach for THIS first when setting up any model with a deforming or adapted mesh — it encodes the combination that does not blow up, tangle, or inject spurious energy, and explains the failure modes so you don't re-derive them. Use before choosing movers, free-slip BCs, restart, or field-transfer options. ---- - -# adaptive-meshing - -The one workable combination for UW3 moving/adaptive-mesh convection, distilled -from many sessions that each re-picked options and stomped on each other's -defaults. **Start from this recipe; change one thing at a time and verify.** - -Reference implementation (current, validated): the **`underworld3.workflows` -adaptive-convection example** — -`docs/examples/workflows/adaptive_convection/` on the `feature/adaptive-convection` -worktree (`config.py`+`simulate.py` no-fault; `fault_config.py`+`fault_simulate.py` -fault; `diagnostics.py`, `render.py`, `compare.py`). Express adaptive runs as a -WORKFLOW (a `WorkflowConfig` + `@workflow_step` DAG + `Run`), NOT a monolithic -driver. The older `scripts/fault_convection_adapt_loop.py` (feature/fault-convection) -is superseded — its ideas are folded into the workflow + this skill. -Companion: the `uw-visualisation` skill for rendering results. - -**Choosing the paradigm:** THIS skill is the **mover** (node movement / -equidistribution, `smooth_mesh_interior`) — the mesh deforms to follow a field. For -**local refinement** instead (`mesh.adapt(...)` returns a refined CHILD; a -fault resolved by a fine band + custom-P FMG + rotated free-slip + dynamic topography -+ advection-diffusion), use the **`adapt-on-top-faults`** skill. Different tools — -don't mix them. For the FMG setup that consumes an adapt child's hierarchy, see the -**`nonlinear-solver`** skill. - ---- - -## PIN THE INTERFACE when you relax a mesh that was refined onto one - -The two operations fight. The mover optimises element **shape** against an -equilateral reference and knows nothing about where the material changes, so it -slides the small cells that refinement placed on an interface *off* it. Measured -on a step-edged fault: manufactured stress across the interface **+77 %**, and it -stopped being confined to the fault. It even *reduces* the number of straddling -cells (1343 → 965) while making things worse, because the survivors are bigger — -leak per straddling cell up 2.5×. - -```python -child.relax(pin_bands=[fault]) # interface = the surface -child.relax(pin_bands=[(fault, 0.02)], pin_halo=2) # weak zone, half-width 0.02 -``` - -Leak unchanged to five decimals, confinement preserved, straddling count identical -— and the mover still reshapes everywhere else. Notes: - -- `pin_halo` (default 1) pins extra rings. Pinning only the cut cells lets the - mover pull on them from outside and drag the pinned ring out of shape anyway. -- `pin_bands` **merges** with `pinned_labels`. Passing `pinned_labels` yourself - REPLACES the default of "pin every named boundary", so a hand-rolled version - that substitutes the band label silently lets the mover deform the domain. -- `mesh.label_interface_band(surface, offset, halo)` is the underlying helper if - you want the label for something else. It uses the SIGNED distance at offset 0 - and the UNSIGNED distance at a non-zero offset — the unsigned distance is never - negative, so a straddle test against it at offset 0 can never fire, and a weak - zone has two margins that the unsigned form catches at once. - ---- - -## Mover quick-start (copy-paste — this is the hard-to-discover bit) - -The user entry is `uw.meshing.node_redistribution(mesh, metric, ...)` (the -purposeful spelling; it dispatches to `mesh.redistribute_nodes`, which drives -the MMPDE mover on 2D simplex meshes — `smooth_mesh_interior` is the -machinery underneath and takes the same kwargs). Minimal correct setup to -adapt a mesh to a field `T` each step: - -```python -import underworld3 as uw - -# metric from |grad T|: refinement=R is a factor on the BACKGROUND spacing h0, -# not a finest:coarsest ratio. The envelope is h in [h0/R, h0*coarsening], and -# coarsening="auto" is R**(1/d) — so R=5 in 2-D spans h0/5 to 2.2*h0, a ratio -# of R**(1+1/d) ~ 11. Use refinement=R, NOT strategy= (caps at ~2, under-grades). -rho = uw.meshing.metric_density_from_gradient( - mesh, T, refinement=5, coarsening="auto", metric_choice="front-following") - -# move the mesh — the mover (Huang-Kamenski MMPDE) is variational, -# non-folding, clusters AND aligns cells. It OWNS field transfer -# (remaps T + SLCN history, fires on_remesh hooks). -uw.meshing.node_redistribution( - mesh, rho, - method_kwargs=dict(step_frac=0.2, accel="cg", momentum=0.0), # mmpde's OWN kwargs - slip_surfaces=True, # boundary nodes slide tangentially (parallel-safe) - skip_threshold=0.9) # skip the move when the mesh is already aligned -``` - -For a **sharp feature (fault)** pass an anisotropic SPD TENSOR metric instead of -the scalar `rho` (thin ACROSS the feature normal n) and bake a gmsh base: - -```python -import sympy -n = sympy.Matrix([nx, ny]) # constant fault-normal unit vector -d = dfac.sym[0] # DIRECT unsigned distance field (P1) -M = rho * sympy.eye(2) + (Rf**2 - 1.0) * sympy.exp(-(d/w)**2) * (n * n.T) -# mesh built with: uw.meshing.Annulus(..., refine_lines=[xy], refine_size_min=smin) -uw.meshing.node_redistribution(mesh, M, - method_kwargs=dict(step_frac=0.2, accel="cg", momentum=0.0), - slip_surfaces=True, skip_threshold=None) # tensor metric: do the skip check yourself -``` - -Pitfalls that make it "not work": `method="anisotropic"`/`"ot"`/`"spring"`/`"ma"` -(RETIRED 2026-07 — they now raise ValueError; mmpde is the default and only -metric mover); injecting `relax`/`n_outer` (starves mmpde's CG); `strategy=` instead of -`refinement=R` (under-grades); a scalar bump for a fault (refines a fat corridor, -leaves the centre coarse); signed `Surface.distance.sym` for `d` (bleeds along the -line extension — use a direct unsigned distance). Full rationale + the rest of the -recipe (BCs, restart, field transfer, cadence) below. - ---- - -## The canonical recipe (defaults that work) - -### 1. Mover — mmpde -`uw.meshing.smooth_mesh_interior(mesh, metric=..., method="mmpde", -method_kwargs=dict(step_frac=0.2, accel="cg", momentum=0.0), slip_surfaces=True)`. -- mmpde = Huang–Kamenski variational, **non-folding** (energy → ∞ as detJ → 0), - clusters AND aligns cells. It is the only clean mover. -- **NOT** the `anisotropic` mover (shreds/freezes on static features) or `OT` - (slivers — OT is optimal transport, sliver-prone, not a route around the cap). -- Do **not** inject `relax`/`n_outer` into mmpde (those are the anisotropic - mover's knobs and starve mmpde's internal CG). - -### 2. Metric -- Thermal: `metric_density_from_gradient(mesh, T, refinement=R, - metric_choice="front-following")`. `refinement=R` (≈5) is the maximum local - refinement **on the background cell size h0**, not the finest:coarsest ratio: - the metric targets `h ∈ [h0/R, h0·coarsening]`, and `coarsening="auto"` takes - the budget-conserving `R**(1/d)`. So R=5 in 2-D asks for h0/5 up to 2.2·h0 — - a finest:coarsest ratio of `R**(1+1/d)` ≈ 11, and ≈ 8.5 in 3-D. Named - `strategy=` caps at ~2 and under-grades. R≈5 extracts ~all the grading the - node budget/layout allows; don't over-tune R (benign no-op above budget). - Passing `refinement` takes the **envelope branch**, which ignores `amp`, - `lo/hi_percentile`, `mode` and `power`. -- Fault / sharp feature: a **hand-built anisotropic SPD tensor** - `M = ρ·I + (Rf²−1)·exp(−(d/w)²)·n nᵀ` (thin ACROSS the feature normal n). A - scalar bump refines a fat isotropic corridor and leaves the centre-line coarse. -- Use a DIRECT unsigned distance field (geometry_tools) for `d`, NOT the Surface's - signed `.distance.sym` (its zero-contour bleeds the metric along the line - extension). - -### 3. Creation vs maintenance (the cap) — and the gmsh base COMPOUNDS -mmpde **cannot create** strong refinement from a uniform mesh — it saturates at a -fixed-topology cap (~1.8× on the fault, re-measured), because the mover has a FIXED -node budget (it redistributes, never adds nodes). To go finer you need MORE NODES, -which only gmsh can add at construction. **Bake refinement into the gmsh base** -(`Annulus(refine_lines=[xy], refine_size_min=...)` — now real, see the Faults -section) and the mover doesn't just MAINTAIN it: the extra gmsh nodes lift it off -its budget cap so it **compounds** (gmsh f2 base 0.44 → mover 0.29; f3 base 0.30 → -mover 0.19 ≈ 5× finer). Measured (Ra1e6 Rf8 res24, all folded=0): -uniform 0.55 (~1.8×) → gmsh-f2 0.29 (~3.4×) → gmsh-f3 0.19 (~5×). Judge by -fault/bulk nearest-neighbour spacing RATIO, never by global misalignment. - -### 4. Cadence — adapt as often as you like (forced every step is FINE) -Adapt every step or every few — on a CORRECT build (see §5) the mover converges -dead-flat and forced every-step adaptation under vigorous convection is stable -(validated: 39/39 forced adapts, mesh folded=0, area-ratio ~14 flat). Skipping -when aligned just saves cost. (An earlier claim that forcing adaptation -"tangles / over-injects energy" was WRONG — that was the deformed-eval bug in §5.) - -### 5. THE bug that wrecked adaptation (holes) — and the fix -SYMPTOM: giant empty cells / holes in the adapted mesh, intermittent area-ratio -spikes, convection wrecked. ROOT CAUSE (proven): `uw.function.evaluate` -**mis-locates points on a deformed mesh** (the nav kd-tree `mesh._nav_coords` was -captured from the ORIGINAL coords and never refreshed), so the metric — built with -strictly-positive nodal values — evaluates to **NEGATIVE garbage** (even at its own -DOFs) → non-SPD → the mover wrecks the mesh. FIX (both): -- **`8a9d2ff2`** (refresh `_nav_coords` + projected normals on every deform) — - the real fix; makes `function.evaluate`/`points_in_domain` track deformation. -- **Monotone RBF metric bake** (in `_mmpde_mover`, formerly `_winslow_mmpde`): Shepard-interpolate the - metric from its **positive nodal values** — a convex average is guaranteed ≥0 - (monotone) + fast (no cell-location). Use RBF for the metric; it doesn't need - high-precision eval. -With these, the metric stays positive/SPD; #259's SPD-floor never fires (harmless). -The mover, `accel`, and `refinement=R` (e.g. R=5) are all FINE — they were -red-herring symptoms of the eval bug. Always confirm a CLEAN BUILD first -(`./uw build`; `md5 site-packages/.../smoothing.py == src`); a stale build is its -own cause of holes. - -### 6. Stokes free-slip -Penalty (`add_natural_bc(KFS·v·n·n)`, KFS≈1e6) or Nitsche γ=10 both work on a -UNIFORM mesh. Nitsche preferred for sharp fault corners. Do NOT diagnose free-slip -with nodal v·n: Nitsche enforces v·n=0 weakly, so nodal v·n is large even when -correct — use vrms (kinetic energy). (An earlier "warm-restart × Nitsche → blow-up, -use cold restart" diagnosis was WRONG / confounded by the §5 eval bug + a stale build.) - -**On an ADAPTIVE mesh, Nitsche free-slip γ=10 is TOO SOFT → intermittent vrms -spikes at adapt steps** (under-enforcement, NOT a blow-up: single-step velocity -garbage that T survives because the solve is cold-restarted — e.g. vrms -144→8887→144). The mover pins the boundary and refines just beneath it, creating -high-aspect-ratio near-boundary cells whose Nitsche inverse-estimate constant needs -a bigger penalty. Two fixes, BOTH good (corroborated across sessions): -- **Nitsche γ=100** (not 10) for a free-slip top on an adaptive mesh — clean, smooth - signal. This is INDEPENDENT of the penalty's mesh-size scaling: local vs global `h` - are equivalent here (both garbage at γ=10, both clean at γ=100). -- **Don't fully pin the boundary in the mover** — let it tangential-slip - (`slip_surfaces=True`, the §1 default), NOT `pinned_labels=[Upper,...]`. Avoiding - the distorted near-boundary cells lets γ=10 work. Pin a boundary only when you must - hold a prescribed shape (e.g. a free-surface height the integrator just set). - -Aside — free-SURFACE held-lid is the OPPOSITE problem: the GLOBAL `h = -get_min_radius` drifts BELOW the surface cell as the interior refines → the Nitsche -penalty OVER-stiffens → a spurious one-step surface "mountain" (vhmax spike). Fix = -a LOCAL per-cell `h` via `mesh.cell_size()` (deformation/adaptation-tracking); -`add_nitsche_bc(..., local_h=True)` is the default (PR #275). So held-lid wants LESS -penalty (local-h), free-slip-adaptive wants MORE (γ=100) — different knobs. - -### 7. Advection + timestep -`AdvDiffusionSLCN(mesh, u_Field=T, V_fn=V.sym, theta=1.0, monotone_mode='clamp')`. -theta=1.0 (backward Euler) for stability; monotone clamp bounds SL overshoot; -`V_fn=V.sym` is the PHYSICAL velocity. **dt can be LARGE — SLCN is unconditionally -stable; the smallest/median adapted cell does NOT have to govern dt.** Use -`estimate_dt(percentile=50)` × a multiplier (e.g. dt_mult 3–5) to advance physical -time faster (needed to develop convection in stiff stagnant-lid regimes). - -### 8. Rheology -- **isotropic** (linear) ⇒ `snes_type=ksponly` (one exact KSP solve; default - newtonls rejects steps on vigorous flow). Cheap. Use for resolved features. -- **TI / anisotropic** weak fault (`TransverseIsotropicFlowModel`: - `shear_viscosity_0=η_FK`, `shear_viscosity_1=η_weak`, `director`=fault normal): - keep the **default newtonls** (NOT ksponly — ksponly converges to the WRONG - answer); use **penalty** free-slip (Nitsche trips the anisotropic-Jacobian bug); - **GAMG** (FMG ~7× slower). Measured on the gmsh-resolved + one-sided base - (Ra1e6 Δη1e3): ran clean to t=0.06, folded=0, vrms→20, **~10×** the isotropic - cost per step (GAMG eats the 1000× anisotropic contrast — well under the feared - 20×). The TI fault visibly steers the flow (persistent recirculation at the trace). -- Viscosity-bearing fields are **P0/P1 only** (positivity; higher order overshoots). - FK viscosity `η = exp(θ(1−T))`, θ = ln(Δη). Floor a weak zone, don't multiply → 0. - -### 9. Build discipline -- `./uw build` after ANY source change; verify `uw.__file__` is in site-packages - and (when debugging) that `site-packages/.../smoothing.py` matches `src/...`. A - **stale mover build is a top cause of "giant empty elements / holes"** in the - adapted mesh. -- **NEVER** `pip install -e .` (contaminates all envs). Run from inside the worktree - (`pixi run -e amr-dev`). - ---- - -## Faults — the gmsh-resolved, on-fault, one-sided recipe (validated 2026-06-23) - -The full fault recipe, hard-won. Reference implementation: the -`underworld3.workflows` adaptive-convection example -(`docs/examples/workflows/adaptive_convection/{config,fault_config}.py`, on the -`feature/adaptive-convection` worktree — built on the FIXED mover base). Use the -WORKFLOW system, not a monolithic driver. - -### gmsh line refinement (`refine_lines`) — NOW IMPLEMENTED in `Annulus` -```python -uw.meshing.Annulus(radiusOuter=1, radiusInner=0.5, cellSize=1/24, - refine_lines=[xy], # list of (N,2) polylines (model coords) - refine_size_min=cellSize/3, # cell size ON the line (factor 2-3 is plenty) - refine_dist_min=0.02, refine_dist_max=0.12) # size ramps back to cellSize -``` -A gmsh **Distance + Threshold** field along the polyline; INTERIOR points are -embedded so nodes land ON the line. Backward-compatible (default `None`). It is a -core meshing change → lands as its own small meshing PR, separate from the workflow -example. (Before this session it was only *called* in old scripts and never existed -— don't trust `refine_lines` on any branch but the one carrying this commit.) - -### Keeping refinement ON the fault (the metric-composition trap) -The fault metric is `M = ρ·I + (Rf²−1)·exp(−(d/wₙ)²)·n nᵀ` with the isotropic SIZE -density `ρ`. **Do NOT fuse the fault density into ρ_T by PRODUCT** — the cold -surface thermal BL (ρ_T ~ R^d ~ 20–25) out-competes the fault near its top and the -refinement drifts ABOVE the fault, starving the deep fault ("seems to repel"): -- Use `ρ = max(ρ_T, fault_ρ)` (NOT `ρ_T · fault_ρ`). -- Make `fault_ρ = 1 + amp·gauss` with **amp > ρ_T** (~25 at R=5) so the fault wins - the max along its whole length. -- DIAGNOSE this by comparing the gmsh BASE (step 0, refinement centered on the - fault — correct) vs the DEVELOPED mesh (drifted above) → it's the MOVER's metric, - not gmsh. Render the mesh with the **fault trace overlaid** (`render.py --fault`). - -### One-sided fault influence (the clean control) -Even with max+amp, the symmetric metric DEMANDS both flanks while realized nodes -drift to the hanging wall. Make it one-sided: -- Store the **SIGNED** distance in `dfac` (the gaussians square it, so magnitude is - unchanged — the sign only feeds a `0.5(1+tanh(m·d/w))` gate). Probe the - radially-outward side once to define "upper" regardless of the distance tool's - orientation convention. -- `fault_metric_side` (both/upper/lower) gates the refinement; `fault_rheology_side` - gates the weak zone. **`both=upper`** is the physical recipe: a one-sided - hanging-wall damage zone with the mesh refined on the same side (refinement and - rheology coincide; gmsh-f3 → fault/bulk ~0.19, folded=0). `metric_side=lower` - instead pulls refinement onto the footwall to counter the upward drift. - -### The wedge fill (anti-collision) -The fault pull and the surface-BL pull compete for the coarse cells in the radial -sliver BETWEEN them. `fault_wedge=True` gmsh-fills that wedge (sample radial -segments from each fault point up to the surface, add as a second `refine_lines` -point set) so both pulls have their own budget and merge into one coherent fine -wedge instead of colliding. - -### Weak zone (rheology) — geometric blend + gaussian PEAKED on the fault (2026-06-24) -The weak-zone viscosity blends `η_FK` (background) and `floor` (fault) by the -influence `f`∈[0,1] (P1, positive). Get it right with TWO rules (verify by -reconstructing + rendering the REALIZED η field — `render_fields.py` — and the -combined `η_FK(T)^(1−f)` field; never assume the floor is reached): -- **GEOMETRIC blend `η_weak = η_FK^(1−f)·floor^f`** (NOT arithmetic - `η_FK·(1−f)+floor·f`, whose `(1−f)` term leaks the stiff-lid background through - → η≈8 at f=0.97 in a 1000× lid). Geometric reaches the floor genuinely (η≈1.2 at - f=0.97) AND ties the contrast to the LOCAL η_FK — so the fault automatically - bites hardest where it cuts the cold stiff lid (physically correct), nothing in - the hot interior. -- **`f` must be PEAKED on the fault (gaussian), NOT a TOPHAT block.** A top-hat - makes a uniform weak BLOCK; its sharp edges mean strain follows ∇f (TWO parallel - lines at the block edges, not on the fault), and a one-sided block is offset to - the hanging wall (η_1=1 core sits ABOVE the drawn line). Use a **gaussian - `f=exp(−(d/w)²)`** (peak f=1 ON the fault → η_1=1 on the drawn line, strain - localizes INTO the slot as a single feature). side=both = symmetric; side=upper = - hanging-wall halo (gaussian taper up, sharp footwall recovery — NO halving gate). - Centre the METRIC too (`metric_side=both`) so nodes refine on the line. -THERMAL CONTRAST (Louis's insight, confirmed): even a symmetric gaussian gives a -much bigger η-contrast on the COLD (upper/surface) side than the warm (footwall) -side, because η_FK rises ~1000× toward the surface — so the fault's dynamical -prominence naturally concentrates in the cold lid. This is a feature, not a bug. -Verified (gmsh-f3, gaussian width=0.025, side+metric=both): weak zone centred on -the drawn fault on the adapted mesh, folded=0. TUNE AT STEP 0 (build mesh+fields, -no solve — fast; plot the 1D η_1(d) profile + the 2D field). COST: genuine 1000× -TI contrast ~25–145 s/step (cold-start steps slow, ~25 s once developed; GAMG). -For TI see §8. (`fault_config.py`: `fault_profile=gaussian` (default still tophat — -pass gaussian), geometric blend in `create_solvers`; `render_fields.py` light maps.) - ---- - -## Failure modes — symptom → cause → fix - -| Symptom | Cause | Fix | -|---|---|---| -| Giant empty cells / holes in adapted mesh; intermittent area-ratio spikes | `function.evaluate` mis-locates on deformed mesh → metric → negative/non-SPD (the real bug). OR stale build. | `8a9d2ff2` + monotone RBF metric bake; `./uw build` + verify md5 site-packages==src | -| Decays when it should convect | over-diffusion OR under-resolution OR dt too small to develop | check a resolved arbiter (finer space+time); raise node budget; **larger dt** (dt_mult 3–5) | -| Fault won't refine under convection | mmpde creation cap; field gives no signal in cold lid | gmsh `refine_lines` base — the gmsh nodes let mmpde COMPOUND past the cap (~5×) | -| Refinement drifts ABOVE the fault / "repels", deep fault starved | fault density fused by PRODUCT with ρ_T → thermal BL out-competes the fault near the surface | `ρ = max(ρ_T, fault_ρ)`; `amp > ρ_T` (~25); or one-sided `fault_metric_side`; +`fault_wedge` | -| Refinement on both flanks but you want one side | symmetric (unsigned-distance) metric | signed `dfac` + `fault_metric_side`/`fault_rheology_side` tanh gate (`both=upper` = physical) | -| TI fault solve: ksponly gives wrong answer | ksponly skips the Picard the inexact GAMG inner solve needs | keep default newtonls; penalty free-slip; GAMG (~10× isotropic cost, fine) | -| free-slip ADAPTIVE: vrms spikes at adapt steps (e.g. 144→8887→144), T stays bounded | Nitsche γ=10 too soft on the distorted near-boundary cells the pinned-top+interior-refine creates (under-enforcement) | **Nitsche γ=100** (not 10); OR don't pin the boundary (tangential-slip `slip_surfaces=True`). h-scaling (local vs global) is moot here | -| free-SURFACE held-lid: spurious one-step surface "mountain" / vhmax spike | global `h=get_min_radius` drifts below the surface cell as interior refines → Nitsche OVER-stiff | LOCAL per-cell h: `add_nitsche_bc(local_h=True)` (default, PR #275) = `mesh.cell_size()` | - -**Diagnose by:** vrms (KE), Nu (`BdIntegral` surface flux), fault/bulk NN-spacing -RATIO (cKDTree), folded-element count + min cell area. Compare runs **at matched -physical time t** (dt differs between meshes), not by step number. When unsure -whether behaviour is physical, build a **resolved arbiter** (uniform mesh finer in -BOTH space and time) — if it agrees with one candidate, that's the truth. - -## Diagnostics (in the workflow example, reusable) -`diagnostics.py`: `mesh_quality` (folded / area-ratio / aspect), `nn_spacing_ratios` -(BL + fault/bulk), `NusseltSurface`, `vrms`, `History`. `render.py`: T+mesh+ -streamlines on the `Run` layout, **`--fault`** overlays the fault trace (read from -the run manifest) + **`--focus-fault`** auto-crops on it + **`--mesh-only`** for the -clean mesh, **`--all`** for every frame. `compare.py`/`fault_refine_plot.py`: -matched-physical-time comparison + fault/bulk-ratio time series. -**Rendering long runs as they go:** a completion-only Monitor is NOT enough — arm a -Monitor that POLLS for new `run.mesh.NNNNN.xdmf` checkpoints and emits the index, so -you render each step as it lands. -**Checkpoint an ADAPTIVE mesh with `meshUpdates=True`** (per-step geometry) or the -saved frames pair deformed fields with stale step-0 geometry. - -## The verified canonical command - -Requires the fix (§5): `8a9d2ff2` (deformed-mesh point-location) + the monotone RBF -metric bake in `smoothing.py`. Validated 2026-06-22 at vigorous Ra1e6/Δη1e3 -stagnant-lid: forced adapt EVERY step (39/39), larger dt, → vrms→18, Nu→1.86, -|v|max 61, mesh CLEAN every frame (folded=0, area-ratio ~14 flat), no abort. - -```bash -# no-fault baseline (the workflow CLI; one flag per config field) -pixi run -e amr-dev python docs/examples/workflows/adaptive_convection/simulate.py \ - --output-dir ~/+Simulations//baseline \ - --rayleigh 1e6 --delta-eta 1e3 --cellsize 0.0417 \ - --resolution-ratio 5 --adapt-every 1 --dt-mult 4 --max-steps 80 --max-t 0.06 - -# resolved fault: gmsh base (factor 3) + on-fault + one-sided hanging wall + TI -pixi run -e amr-dev python docs/examples/workflows/adaptive_convection/fault_simulate.py \ - --output-dir ~/+Simulations//fault \ - --rayleigh 1e6 --delta-eta 1e3 --cellsize 0.0417 --resolution-ratio 5 \ - --fault-base-smin 0.0139 --fault-anisotropy 8 \ - --metric-combine max --fault-refine-amp 25 \ - --fault-rheology-side upper --fault-metric-side upper \ - --rheology ti --dt-mult 4 --max-steps 40 --max-t 0.06 -``` - -Key choices, verified: -- **`--resolution-ratio 5`** (R=5) is fine — the mover handles it on a correct build. -- **`--adapt-every 1`** force adapt every step (the strongest mesh test). -- **`--dt-mult 4`** — larger dt for STABILITY is fine (SLCN unconditional); but it - costs transient ACCURACY (over-diffusive backward-Euler DELAYS the convective - onset vs a resolved arbiter — dt×1.5 recovers it). Use small dt_mult for faithful - transients, large to reach quasi-steady fast. -- **`--freeslip penalty`** (default) — REQUIRED for TI (Nitsche trips the - anisotropic-Jacobian bug). It's the raw velocity penalty `kfs·(v·n)n`. -- Fault: `--fault-base-smin` (gmsh resolve), `--metric-combine max` + - `--fault-refine-amp 25` (keep refinement ON the fault), `--fault-*-side` (one-sided), - `--rheology ti` (real fault). Drop the fault flags for the no-fault control. - -Render with `render.py` (`--fault --focus-fault` to see the trace + refinement -coincide; `--mesh-only`; `--all`). Judge the mesh by folded/area-ratio, the physics -by vrms/Nu, ALWAYS at matched physical time. - -## Related memory -`project_adaptive_convection_as_workflow` (THIS session: workflow port, gmsh -refine_lines, on-fault/one-sided/wedge, TI, dt-accuracy), `project_mmpde_holes_real_root_cause`, -`project_fault_refine_fixed_topology_cap`, `project_uw_workflow_landing`, -`feedback_debug_adaptive_solver_method`, `project_fault_convection_working_settings`. diff --git a/.claude/skills/adaptive-meshing/SKILL.md b/.claude/skills/adaptive-meshing/SKILL.md new file mode 120000 index 000000000..13942c4af --- /dev/null +++ b/.claude/skills/adaptive-meshing/SKILL.md @@ -0,0 +1 @@ +../../../docs/developer/guides/adaptive-meshing.md \ No newline at end of file diff --git a/.claude/skills/cetz-figures/SKILL.md b/.claude/skills/cetz-figures/SKILL.md deleted file mode 100644 index f65e81a3a..000000000 --- a/.claude/skills/cetz-figures/SKILL.md +++ /dev/null @@ -1,143 +0,0 @@ ---- -name: cetz-figures -description: Build schematic / labelled-geometry figures for underworld3 papers using Typst + cetz. Use when the figure is primarily about topology, annotation, and math-typeset labels (meshes, solver diagrams, flow charts). Prefer Python → SVG → `#image()` for data-heavy figures (fields, colormaps, arrow plots) instead. ---- - -# cetz-figures - -Scaffold for Typst/cetz figures in `publications/**/figures/` alongside the -existing `arrays-sync-flow.typ`. This skill exists because upstream Claude -sessions draft cetz blind — this one actually compiles. - -## When to use cetz - -- Mesh schematics with a few labelled triangles / vertices / control points. -- Solver / data-flow diagrams (see `arrays-sync-flow.typ` in this repo). -- Anything where labels should render in the paper's math/text fonts. -- Anything that benefits from recompiling with the paper. - -## When to use something else - -- **Data-heavy plots** (scalar fields, colormaps, quiver plots, anything with - dense per-pixel or per-cell data) — generate SVG from Python/matplotlib, - include via `#image("foo.svg")`. cetz will fight you here. -- **Geometry computation** (Delaunay, intersections, interpolation) — do it in - Python offline, emit JSON with the shape `{"vertices": [...], - "triangles": [...], ...}`, let Typst just draw. - -## When NOT to use TikZ - -Evaluated and rejected: -- Slower compile than Typst. -- Drags in a LaTeX toolchain that isn't otherwise required by the project. -- No Typst math-font advantage over cetz for labels in our paper context. - -Keep TikZ in back pocket only if a co-author insists on TikZ source. - -## Before you draw (thesis-first discipline) - -When a user hands you a figure request — especially one replacing an -existing ASCII sketch, whiteboard photo, or reference figure — **do not -start transcribing**. The source artefact is a hypothesis about what -to communicate, not a specification. Before opening cetz: - -1. **State the thesis in one sentence.** What is the figure arguing? - If you can't write it plainly, you don't yet understand the figure. - Ask the user to articulate it. - -2. **Honestly audit the source.** If the original ASCII / sketch is - being replaced, it's often replaced *because it doesn't land well*. - Say what's broken about it before proposing the replacement — the - user often agrees and the new figure can do more than the old. - -3. **Enumerate design decisions as explicit questions, not assumptions.** - For a curved-boundary normals figure that's typically: - - Geometry (circle / ellipse / arc span / zoom level) - - Sampling density (number of facets, quadrature points per facet) - - Overlay vs. side-by-side - - Whether to show error quantitatively (arcs, annotations) or leave - it as a visible angle - - Where the figure lives in the repo (which doc / which branch) - Present a proposed interpretation with the decisions flagged; let - the user resolve them before you compile. - -4. **Only then open cetz.** Iterate visually — the discipline above is - about not committing to a design prematurely, not about planning - exhaustively. Once you start, compile often. - -The user's phrase "I'm not quite sure what this is intended to -illustrate" is the canonical trigger for this discipline. If you hear -it (or catch yourself about to transcribe without checking), stop and -do the four steps above. - -## Key gotchas (hit during iteration — not hypothetical) - -1. **Don't give a helper a parameter named after a `cetz.draw` export** - — `anchor`, `fill`, `stroke`. The cause is the `import cetz.draw: *` the - helper needs (gotcha 6): it runs *inside* the function body and shadows the - parameter, so the parameter name resolves to cetz's function rather than to - the value you passed. `anchor` panics with `"Unknown anchor 'anchor' for - element 'none'"`; `fill` gives `"expected color, gradient, tiling, or none, - found function"` pointing into `canvas.typ`, nowhere near your code. Rename - to `align-to`, `bg`, `edge`. See `cetz-cheatsheet.md`. - -2. **Clipping is a Typst concern, not a cetz one.** cetz has no `\clip`. - Wrap the canvas in `#box(clip: true, width: ..., height: ..., ...)` and - draw slightly oversized inside the canvas — the box clips the overflow. - -3. **Painter's algorithm — order matters.** Draw the background first, the - highlight second. No z-index exists. Verified in `mesh-demo.typ`. - -4. **Semi-transparency via `rgb(r, g, b, a)`** (alpha 0–255) or - `color.transparentize(col, 50%)`. Both work for fill and stroke. - -5. **Math in labels just works.** `content(pos, $v_1$)` renders in the - document math font. No escape hatch needed. This is a real cetz win over - SVG. - -6. **`import cetz.draw: *` inside the canvas closure.** Without it, `line`, - `circle`, `content` aren't in scope. Helper functions that draw need - their own `import cetz.draw: *` line inside. - -## Project layout pattern - -Each blog post or paper section gets its own subdirectory under -`figures/`, so a post's figures travel together: - -``` -publications/blog-posts/figures/ -└── / - ├── .typ # cetz drawing - ├── .png # committed output - ├── -data.json # (optional) precomputed geometry - └── generate-.py # (optional) Python that writes the JSON -``` - -Concrete example: `publications/blog-posts/figures/finding-particles/` -holds `mesh-demo.*` and `domain-demo.*` for the post -`finding-particles.md`. - -The JSON intermediate is the forward bridge to underworld3 — see -`underworld-bridge.md`. - -## Reference files - -- `cetz-cheatsheet.md` — what worked from memory vs. needed lookup. -- `underworld-bridge.md` — JSON schema for future `uw.meshing` export. -- `examples/` — self-contained copies (each with `.typ`, `.png`, `.json`, - and generator `.py`) of the figures this skill is scaffolded from. - These are snapshots; the live versions may have drifted if a post or - doc was iterated on further. - - `mesh-demo.*` — element-level point-in-cell test. - Live: `publications/blog-posts/figures/finding-particles/`. - - `domain-demo.*` — parallel domain centroid ambiguity. - Live: `publications/blog-posts/figures/finding-particles/`. - - `facet-vs-true-normals.*` — facet normal vs. smooth-surface normal - on a curved boundary. Live: - `docs/advanced/figures/curved-bc/`. - -## Canonical reference in the repo - -`publications/blog-posts/figures/arrays-sync-flow.typ` — prior cetz figure -in the repo, established version (0.3.4) and house style (hex colours, -helper-function pattern). Follow its conventions. diff --git a/.claude/skills/cetz-figures/SKILL.md b/.claude/skills/cetz-figures/SKILL.md new file mode 120000 index 000000000..3ea8ffa2a --- /dev/null +++ b/.claude/skills/cetz-figures/SKILL.md @@ -0,0 +1 @@ +../../../docs/developer/guides/cetz-figures.md \ No newline at end of file diff --git a/.claude/skills/free-surface-convection/SKILL.md b/.claude/skills/free-surface-convection/SKILL.md deleted file mode 100644 index b4e4c4bfb..000000000 --- a/.claude/skills/free-surface-convection/SKILL.md +++ /dev/null @@ -1,224 +0,0 @@ ---- -name: free-surface-convection -description: The Underworld3 free-surface convection method we are hardening — the THREE-NUMBER pointwise topography integrator (held-lid stress equilibrium h_∞ + L-stable exponential relaxation), NOT FSSA. Reach for THIS before touching any free-surface / dynamic-topography convection run, choosing a surface-update scheme, or "stabilising" a free surface. It records the method, why FSSA is explicitly rejected, and the failure modes. ---- - -# free-surface-convection - -The free-surface scheme used in `~/+Simulations/FreeSurface/convection/fs4_compare.py` -and the design doc `docs/developer/design/FREESLIP_DYNAMIC_TOPOGRAPHY_FREESURFACE.md`. -**This is the method we are HARDENING — do not replace it; do not add FSSA.** - -> The 3-number integrator is necessary but NOT sufficient — see **Hardening strategies -> (2026-06)** below for the material-surface advection, tangential topography term, -> free-slip-inner nullspace, and graded/higher-order-mesh fixes that make it actually -> work. Reference impl + diagnostic tools live in `~/+Simulations/FreeSurface/convection/`. - -## ⚠️ NOT FSSA - -The `docs/examples/free_surface/advanced/Annulus*FS.py` examples use **FSSA** -(`add_natural_bc(δt·(Γ·v)Γ/2, "Upper")`). **That is NOT our method.** FSSA buys -stability by adding an implicit surface traction that **UNDER-deforms the surface** -— it trades accuracy for stability. Our scheme is designed to be stable **and** -accurate. If you find yourself adding `FSSA`, an `add_natural_bc` traction on the -free surface, or a `Gamma.dot(v)` stabiliser — STOP, you have the wrong method. -(Those example files are a template for a *different* approach, not this one.) - -## The three-number pointwise integrator (THE method) - -Two Stokes solves per step on the SAME mesh, then a pointwise surface update: - -1. **Free solve** — stress-free top (NO velocity BC on `Upper`; pressure datum is - pinned by the stress-free condition → no pressure nullspace). The surface - normal velocity `u_n` of this solve IS the kinematic rate `ḣ`. -2. **Held-lid solve** — a second Stokes solve with a RIGID free-slip held lid - (`u_n = 0`, via `add_nitsche_bc(0.0, "Upper", local_h=True)` — see [[project_nitsche_local_h_pr275]]) - and a DRIVING-ONLY body force. Its surface normal stress `σ_nn` gives the - equilibrium topography `h_∞ = -(σ_nn - mean)/ρg`. (The free solve forces - `σ_nn = 0`, so the equilibrium MUST come from the held-lid stress.) -3. **Pointwise exp step**, per surface node, from THREE numbers (`h`, `ḣ=u_n`, - `h_∞`): - ``` - γ = ḣ / (h_∞ − h) # local relaxation rate, clamp γ ≥ 0 - h ← h_∞ + (h − h_∞)·exp(−γ·dt) - ``` - L-stable: the step is bounded between `h` and `h_∞`, so it **cannot overshoot** - regardless of a noisy local `γ` (no "drunken sailor"). 1 extra solve/step; - beats RK4 at large dt. NO per-node freeze-clamp (that was the old `relax` bug). -4. The nodal surface increment is **carried inward by a Laplacian diffuser** - (smooth, minimal mesh deformation — NOT full mmpde adaptation), then - `mesh.deform()`. Uniform meshes are fine; adaptivity is NOT required. - -Reference impl: `fs4_compare.py` → `_surface_step`, `_h_inf` (held-lid σ_nn via a -`Projection`), `_surf_un`, `_carry_diffuser`. The free-slip RIGID-top run (no -surface motion) is the reference; the free-surface run is the same driving solve -PLUS this surface update. See [[project_fs4_adaptive_2x2]], -[[project_stress_equilibrium_freesurface]], [[project_freeslip_topo_freesurface]]. - -## Performance - -- Free-slip (rigid top) stagnant-lid runs are FAST. The free-surface cost is the - extra held-lid solve **plus** that moving the surface forces a COLD-START Stokes - each step (can't warm-start across a deformed mesh). -- Use **uniform** meshes for this problem — the diffuser gives minimal deformation, - no mmpde needed. (FMG works on a uniform `refinement=N` hierarchy; scalar solvers - must avoid FMG — PETSc err62, issue underworldcode/underworld3#276.) - -## Hardening strategies (2026-06) — the integrator alone is not enough - -The 3-number integrator moves the surface correctly, but several *other* things must -be right or it runs away / tangles. All implemented in `fs4_compare.py` (flags noted). - -### 1. Material-surface advection — THE key fix (`--advect-velocity`) -The runaway (`u_n` 42→125→285→445, cold lid leaking in, plumes punching through) was -NOT an `h_∞`/BC bug (`h_∞` is verified correct, even in the stagnant FK lid — held-lid -free-slip is the EASY case there). The bug: the surface moves by the L-stable relaxed -rate `ũ_n = Δh/Δt ≤ u_n`, but T was advected with the **stress-free solve velocity** -(surface-normal = full `u_n`). Net material then crosses the surface. A free surface is -a MATERIAL boundary: advect T with a velocity whose surface-normal = `ũ_n`. Modes: -- `consistent` (the right way): a THIRD Stokes solve, same buoyancy, `v·n̂ = ũ_n` - PRESCRIBED at the surface (penalty), tangential stress-free. `ũ_n = (shape_new−shape0)/dt` - = the full ∂h/∂t at fixed θ (correct ALE target). -- `blend`: `α·v_free + (1−α)·v_held`, `α = φ1(γΔt) = (1−e^{−γΔt})/(γΔt)` (the exp-decay - time-average). By Stokes LINEARITY this *is* the prescribed-`ũ_n` solve for UNIFORM α - (and free for FK, which is linear in v). BUT the single mean-α collapse is NOT close - enough once γ varies per surface node — the planform diverges (mode-3 vs mode-1/2), - throughflow ~23 vs ~0.08. Per-node α breaks div-free (∇α·(v_free−v_held)). So the - per-node `consistent` 3rd solve is REQUIRED for structured planforms. -- `free`: advect with stress-free v (the inconsistent baseline — the runaway). - -### 2. Tangential topography advection (`--no-tangent-advect` to disable; default ON) -The pointwise relaxation omits the `v_t·∂_s h` term — a surface rotation/convergence -should carry the topography pattern along the surface; without it you get edge artefacts -where ∂_s h is large (plume-bulge edges). Fix = operator split per step: (1) departure- -point semi-Lagrangian transport of the surface shape in θ by `ω = v_t/r`, then (2) the -L-stable normal relaxation. Lowers throughflow + improves mesh quality. - -### 3. Free-slip inner boundary — rotation nullspace (`--inner freeslip`) -The rigid rotation `[-y,x]` is a velocity nullspace ONLY while the boundary is CIRCULAR. -Once the free surface DEFORMS, do NOT attach it to the held/consistent solves (→ held -22 s/`DIVERGED_LINEAR_SOLVE`, throughflow blow-up). Keep `petsc_use_pressure_nullspace`; -strip the gauge with the exact post-solve projection `_project_out_rotation` on `v`, -`v_cons` (drives advection) AND `v_h` (one consistent non-rotating frame). The undeformed -free-slip *reference* (`--surface freeslip`) is fine WITH the nullspace attached. - -### 4. Graded / higher-order meshes (drive node movement consistently) -- **Surface-ring detection**: tie the tolerance to the FINEST cell - (`0.5·mesh.get_min_radius()`), NOT the nominal `cellsize`. On a gmsh-graded mesh - (`cellSizeOuter`) the old tolerance scoops the first interior ring → a 2%-thick - "surface band" → tangling (looks like the surface "destroying itself"; it isn't — - the diffuser was fed a corrupt surface). BETTER (TODO): build the ring from the DMPlex - `Upper` label (`dm.getLabel("Upper").getStratumIS`), removing the tolerance entirely. -- **Node movement**: the solve velocity is P2; the mesh geometry is P1. Drive `u_n` and - the tangential transport from a P1 length-smoothed `Vector_Projection` of V (`v_p1`), - NOT a point-evaluation of the P2 field. -- **Stress smoothing**: `topo_proj.smoothing_length` = a fixed PHYSICAL length - (`--smooth-length`), not cell-count, so `h_∞` is mesh/order-independent. - -### 5. Cost — there is no acceleration win (don't chase it) -3 Stokes solves/step (free→u_n, held→h_∞, consistent→advect). Warm-start does NOT help -(outer KSP already 1 iter; FMG supplies its own nested guess — measured SLOWER). Blend- -skip rarely fires (α-spread always large). Operator/PC reuse: already reused across -`solve()`s (first 5.5 s setup, steady ~945 ms = irreducible FMG solve; RHS-only resolve -same cost). The per-step cost is the geometric FMG hierarchy REBUILD on the deforming -mesh — intrinsic to moving meshes, "live with it." The UNIFIED-PENALTY single solver -(`penalty·(v·n̂ − V₁·n̂)·n̂`; penalty=0→free, V₁=0→held, V₁=ũ_n→consistent — held & -consistent share the matrix) is the cleanest formulation (no recompile on the constant) -but doesn't cut the irreducible solve. - -### Elastic-plate flexure `h_∞` — IMPLEMENTED (`--flexure-D`) -Generalizes the LOCAL Airy `h_∞ = −σ_nn/ρg` (the D=0 limit) to a flexed plate -`(D ∂_s^4 + ρg) h_∞ = −σ_nn`, solved SPECTRALLY on the ring (serial Fourier — the -feasible substitute for UW3's blocked 1D-manifold FE solve): per mode -`h_∞(m) = −σ_nn(m)/(ρg + D(m/r_o)⁴)`. `D` sets the flexural wavelength `(D/ρg)^{1/4}` -and damps short-wavelength loads — the physically-grounded, mesh-independent length- -smoothing. In `_h_inf` (h_∞-ONLY — the stable form). **PROTOTYPE — amplitude response correct -(stiffer plate → less deflection) but it does NOT low-pass the SURFACE**: filtering `h_∞` only -sets a smooth set-point; the surface still picks up short-wavelength content from the SL -tangential transport + partial relaxation. "Filter every surface number (h, ḣ, h_∞)" was -TRIED and REJECTED — filtering the GEOMETRY `h` injects a spurious smooth-the-mesh motion into -`ũ_n` → flow runs away (Vrms 50→345); filtering `ḣ` alone is stable but elevates Vrms with no -benefit. So making flexure a TRUE surface low-pass without destabilising is OPEN/hard -(`_flex_filter` helper is in place). Examples: `stagnant_lid_mode1_study/{figures/flexure_*.png, -runs/flexure_D*}`. - -### Open / next (not yet done) -- **Label-based surface ring** (replace the radial heuristic with the `Upper` stratum). -- **Flexure D calibration** to a realistic lithospheric flexural wavelength. - -### Diagnostic tools (`~/+Simulations/FreeSurface/convection/stagnant_lid_mode1_study/scripts/`) -- `heldlid_hinf_check.py` — verify `h_∞` via 4 independent free-slip enforcements × Δη sweep -- `stitch_compare.py` — side-by-side montage of per-run dirs (`--dirs a,b,c`) -- `unified_penalty_solver.py` — the one-solver penalty formulation probe -- `resolve_timing_probe.py` — repeated-solve / lag-Jacobian / reuse-PC timing - -## ★★ Body force must be FULL Boussinesq on a deforming mesh (2026-07-26) - -**On any run where the mesh surface actually moves, the body force must retain the -ρ₀ background: `bodyforce = (thermal_buoyancy − rho_0_g)·r̂` (i.e. ρ = ρ₀(1−αΔT)).** -The reduced (driving-only) form has **NO restoring force for surface deformation -anywhere in the momentum system** — `buoyancy_scale` enters only the kinematic target -h∞ = −σ_nn/ρg, which exerts zero force on the flow. Consequences and evidence (all -measured, `~/+Simulations/FreeSurface/annulus_fs_convection/teaching/`): - -- **Relaxation A/B (`relaxation_test.py`)** — imposed 5% mode-4 topography, no thermal - driving: reduced form = surface FROZEN (velocities are round-off); full density = - **262→5 km in 10 steps at the Cathles rate** (measured 2.1e4 vs ρ₀g/(2ηk) = 2.5e4, - within 20% at res 0.06). -- **Convection A/B (`restoring_force_demo.png`, gap-Ra 3e4, ρ₀g 1e6)** — reduced h - grows monotonically without limit; full density *rings* about its supported amplitude - early (damped), then tracks the developing flow ~35% lower with HIGHER Nu (1.93 vs - 1.65). Without the term, soft surfaces run away entirely (measured to 50% of radius). -- **The FreeSurface manager needs NO change** — held/consistent solves inherit - `stokes.bodyforce`; h∞ then self-consistently includes the self-load (a moving target - the integrator follows cleanly — verified in the relaxation test). - -Rules that come with it: -1. **ρ₀g and Ra are COUPLED: αΔT = (thermal buoyancy coeff)/ρ₀g must be ≲ 0.3.** - Softness is not a free knob — the old "soft" runs at ρg=2e5 were αΔT = 1.2–4 (no - such fluid) and their runaway was partly parameter nonsense. Soft surfaces require - weak driving. -2. **Tighten the solver tolerance (~1e-8)**: the hydrostatic RHS dominates the dynamic - signal by 1e3–1e4, and a relative tolerance judges the total. -3. Prefer `snes_type=ksponly` for the isoviscous solves; the huge RHS makes `newtonls` - thrash worse. -4. Driver switch: `fs_convection.py -uw_full 1`. -5. **`FreeSurface(background_buoyancy="analytic")` is REQUIRED with full density** - (98961147): the recovered reaction contains the self-load +h_current and the - reduced-form negation otherwise flips it -> h_inf = -h + drive/rho_g (parks at HALF - equilibrium; -1 eigenvalue = period-2 ringing; steady flow THROUGH the stationary - surface). "analytic" subtracts the geometric height - no extra solve. The CBF - recovery itself is exact (probe lesson: select boundary DOFs by LABEL, never a - radius mask, on a deformed mesh). `background_buoyancy=` = exact two-reaction - reference mode. - -Related transport fact (same campaign): the serial T-blow-up on deforming FS meshes was -the **old-frame SL reach-back** amplifying per loop cycle (issue #423; smaller dt makes -it WORSE) — retired in `b507aca1`; the manager now uses the standard ALE path + clamp + -deform-aware foot restore. The parallel datum defect is #421. - -## Failure modes — symptom → cause - -| Symptom | Cause | -|---|---| -| Surface deforms but `u_n` RUNS AWAY (e.g. u_n 42→125→285→445), cold lid leaks in, plumes punch through | **RESOLVED**: material-surface advection inconsistency — T advected with stress-free `u_n` while surface moves by relaxed `ũ_n`. Fix = `--advect-velocity consistent` (Hardening §1). NOT an h_∞ bug, NOT fixed by FSSA. | -| `held` solve 22 s / `DIVERGED_LINEAR_SOLVE`, throughflow blows up, with `--inner freeslip` | rigid-rotation `[-y,x]` attached as a nullspace on the DEFORMED (non-circular) surface — invalid. Don't attach it on the moving surface; use the post-solve projection (Hardening §3). | -| Graded-mesh surface "destroys itself" (q→0.2, h_max 2% at step 1) | surface-detection tolerance scooped the first interior ring → 2%-thick band, NOT real deformation. Tie tolerance to finest cell (Hardening §4). The diffuser is innocent. | -| Topography GROWS without saturating; flow-through persists even at huge deformation | **missing ρ₀ background** — reduced body force has no surface restoring force (see the Full-Boussinesq section above). Fix = ρ = ρ₀(1−αΔT); check αΔT ≲ 0.3 | -| T leaves [0,1] on a deforming mesh, mesh-locked hot/cold spikes in the squeezed band, worse at SMALLER dt | old-frame SL reach-back amplifying per loop cycle (issue #423) — use the standard ALE path (`old_frame_traceback=False`, the manager default since b507aca1) | -| Stress-free top but surface not updated each step | nothing stops throughflow (the stress-free top is an open boundary unless the integrator moves the surface to track `u_n`) | -| Nu decays when it should be steady (kinematic free surface) | LAG: the SL foot reaches beyond an under-moved surface → cold pump. Fix = the h_∞ relaxation, not more smoothing | -| Surface "mountain" / one-step spike on adaptive mesh | held-lid Nitsche penalty over-stiffened by GLOBAL h; use `local_h=True` (default, PR #275) = `mesh.cell_size()` | - -## Dead ends (already tried — do NOT repeat) - -- **FSSA** signed-traction free-surface: diverges / under-deforms — rejected. -- **High-k post-smoothing** of the surface: the instability is low-m, smoothing - the wrong band. -- Per-node freeze-clamp in the relaxation (the old `relax` fatal bug). - -## Diagnose by - -`h_max` (deflection, as % of r_o), `u_n` / `vhmax` (surface throughflow — should NOT -grow unbounded), `hinf_max` (the equilibrium target), `vrms`, `Nu`. Compare the -free-surface run against the free-slip RIGID-top reference at matched physical time. diff --git a/.claude/skills/free-surface-convection/SKILL.md b/.claude/skills/free-surface-convection/SKILL.md new file mode 120000 index 000000000..7166340ad --- /dev/null +++ b/.claude/skills/free-surface-convection/SKILL.md @@ -0,0 +1 @@ +../../../docs/developer/guides/free-surface-convection.md \ No newline at end of file diff --git a/.claude/skills/nonlinear-solver/SKILL.md b/.claude/skills/nonlinear-solver/SKILL.md deleted file mode 100644 index 297b3df85..000000000 --- a/.claude/skills/nonlinear-solver/SKILL.md +++ /dev/null @@ -1,321 +0,0 @@ ---- -name: nonlinear-solver -description: How to make a hard nonlinear Stokes solve (Drucker-Prager / yield-stress viscoplastic) CONVERGE reliably in Underworld3 the way the working recipe actually does it — automatic warm-start (one Picard step on a cold start) plus a MULTI-SOLVE δ-continuation (constant δ per solve, warm-start the next, sharper δ), the consistent-Newton tangent, and a non-symmetry-safe multigrid smoother. Reach for THIS when a viscoplastic solve stalls / diverges and you are about to hand-tune PETSc options, ramp δ, or "just add a monitor". It carries the CONFIG TRAP LIST — the setup mistakes that each produce a different failure a few steps in — and the one thing you must NOT do (ramp δ inside a single SNES solve). For the yield-law maths and which tangent per model, see `plasticity-solvers`. ---- - -# nonlinear-solver - -The recipe that gets a **hard viscoplastic (Drucker–Prager) Stokes** problem to -converge, and — more importantly — the list of setup mistakes that stop it. The -central lesson from the Spiegelman hard-case study (`η_bg=1e26`, `V=10`): every -failure was a **solver-configuration** error, not a bad Jacobian. If the correct -setup is a minefield for an expert, that is an API regression — so the goal is to -make the correct path the default path. - -Design of record: `docs/developer/design/nonlinear-solver-homotopy-warmstart.md`. -Yield-law maths, tangent-per-model, quadratic-convergence check: `plasticity-solvers`. - ---- - -## The recipe (what actually converges) - -1. **Warm start.** Start the continuation at **large δ**, where the yield surface is - smooth and the problem is easy, and take **one Picard step** into the Newton - basin. One Picard step is defect-correction iteration 1 — contractive, cheap. From - a *warm* iterate, take **no** Picard step (it wastes the good quadratic start). - A cold `v=0` start is safe on its own terms: `ε̇=0` makes `η_pl` infinite, which - the soft-min carries to the viscous branch (see the trap list for the one form - that must be written carefully). - -2. **If a single solve at the sharp surface fails**, escalate in this order: - **grid sequencing first** (solve coarse, transfer, re-solve fine — the - measured 2-3x win at the notch), and only then a **multi-solve - δ-continuation** as the rescue of last resort. The δ-discipline, when you do - reach for it: hold δ **constant** for a full nonlinear solve to tolerance; - warm-start the next, smaller δ from that converged state; march down to the - sharp surface. δ is a `constants[]` atom, so each step is a recompile-free - `PetscDSSetConstants` update. - - The packaged driver is `stokes.solve(homotopy=True)` (also callable directly - as `underworld3.systems.yield_continuation`; tune with - `homotopy_options=dict(delta0=…, down=…, dmin=…, entry_maxit=…, - step_maxit=…)`). **Treat it as a rescue, not the default**: the evidence - that once made a δ-march the recommended entry point was retracted (it - rested on a unit-scaling error — `plasticity-solvers` carries the ruling - and the surviving evidence), and the driver's documented cold-start - guarantee does not currently hold (issue #473: entry can fail on a - pressure-dependent yield, and the step control is effectively one-shot). - Newton + the automatic Picard entry handles the standard cases without it. - -3. **Consistent-Newton tangent** for non-elastic DP (`consistent_jacobian=True`); - **Picard** for elastic VEP — see `plasticity-solvers` for the per-model table. - -4. **`bt` line search** with the consistent tangent on a smooth (δ>0) surface. - ---- - -## DO NOT ramp δ inside one SNES solve - -Ramping δ **inside a single SNES solve** (a `SNESSetUpdate` callback that sharpens -the yield surface between Newton iterations) is **proven dead** — it diverges -`DIVERGED_LINEAR_SOLVE` after ~2 iterations and grinds for ~2 hours, **even on the -proven solver config**. Mechanism: the continuation only sharpens δ from a -**converged**, well-conditioned iterate; the in-SNES ramp sharpens δ **mid-solve** -at a far-from-solution iterate where the consistent-Newton Jacobian on a sharpening -surface is ill-conditioned and the linear solve fails. **Hide the *continuation*, -not the *ramp*.** (An in-SNES ramp API once shipped and has been removed from the -source entirely — use the multi-solve continuation above; `plasticity-solvers` -carries the yield-law substrate and the evidence on when a δ-march is worth it -at all.) - ---- - -## CONFIG TRAP LIST - -Each of these produces a *different* failure a few steps in — that is why the hard -case felt like whack-a-mole. Check them first. - -| Trap | Symptom | Fix | -|---|---|---| -| **Perfect plasticity's consistent tangent is SINGULAR along the flow**: on the hard-`Min` plastic branch η = τ_y/2ε̇_II, so 2η + 2η′ε̇_II = 0 — the velocity block is symmetric but semi-definite in every yielded cell. (An earlier version of this row blamed *asymmetry*; that is wrong for any η(ε̇_II) law — the rank-one term η′ ε̇⊗ε̇/ε̇_II is symmetric. Pressure-dependent yield adds a non-symmetric v–p coupling, not a non-symmetric velocity block. Corrected 2026-08-26, maintainer review.) | benign while yielded cells are few (the viscous neighbours regularise); with a large yielded fraction the velocity sub-solve caps out and Newton stalls at ~1e-3, no failure reason | give the plastic branch a positive tangent: a small δ soft-min (`yield_mode="softmin"`, powermean, `yield_anchor="yield"`), a rounded viscosity floor, or rate-strengthening ξ; Picard converges regardless (full 2η stiffness) but is linear-rate. The FMG bundle's `gmres`+`sor` smoother is Newton-safe either way | -| `preconditioner="fmg"` (vs explicit `pc_type=mg` + manual mg opts) | outer KSP "converges" in **1 iteration** → no real Newton correction → stall → `DIVERGED_LINE_SEARCH` | use explicit `pc_type=mg` with the smoother opts above; bound the outer KSP (`ksp_max_it`~80) so a hostile step fails fast | -| Cold plastic start `v=0`, or any rigid/unyielded point | `DIVERGED_FNORM_NAN` at iteration 0 | **Not** a div/0: `ε̇=0` gives `η_pl=+inf`, which `Min` and the sqrt soft-min carry correctly to the viscous branch. Only a soft-min form that computes `η_ve·η_pl/(η_ve+η_pl)` breaks (`inf/inf`). Fixed in the power-mean; if you hand-roll a blend, write the harmonic mean as `η_ve/(1+η_ve/η_pl)`. **Do not reach for a strain-rate floor** — it hides this rather than fixing it | -| LU velocity block with all-Dirichlet-ish BC | pressure nullspace singular | attach the Stokes nullspace / avoid a bare LU there | -| Hand-rolled `snes_monitor` to "see what's happening" | you read residuals but miss the tell | use `solve_with_diagnostics` / `get_snes_diagnostics` instead (below) | - -**The diagnostic tell:** `solver.get_snes_diagnostics()["linear_iterations"] ≈ 1` -per Newton step means the linear solve is doing **no real work** (the FMG-1-iteration -trap). A healthy consistent-Newton solve does real Krylov work each step and -converges quadratically. Use `solve_with_diagnostics()`, not a hand-rolled monitor. - ---- - -## Automatic warm-start (Layer 1 — landed) - -`solver.has_solution` is a **public, read-only** status flag: `True` only after a -solve whose SNES converged; reset on a structural rebuild (remesh / adapt / -mesh-mover — the `is_setup=False` hook); kept through coefficient changes (viscosity, -δ, BC values, time step). A **diverged** solve leaves it `False`, so the next solve -auto-cold-starts rather than warming off a corrupted iterate. - -On a **cold** (`zero_init_guess=True`) Stokes solve under the **consistent-Newton -tangent**, a single Picard step is now taken automatically (reusing the existing -`picard=1` machinery). The default (frozen) tangent path is bit-identical. - -```python -stokes.consistent_jacobian = True -stokes.solve() # cold → one automatic Picard step, then Newton -if stokes.has_solution: - ... -``` - ---- - -## Implementation status (this line of work) - -- **Layer 1a — DONE:** `has_solution` + cold consistent-Newton Picard warm-up - (`petsc_generic_snes_solvers.pyx`; test `test_0201`). -- **Layer 1b — DONE:** `zero_init_guess` is tri-state — `None` (default) auto-detects - from `has_solution`, `True` forces fresh, `False` insists on warm. Note warm and cold - agree only to the *convergence tolerance*, not bitwise. -- **Layer 3 — DONE:** the FMG velocity smoother defaults to `gmres`+`sor` with - `mg_levels_ksp_norm_type=none` (fixed-cost V-cycle), unconditionally — see - "Multigrid depth" below. -- **Layer 2 — SHIPPED, DEMOTED TO RESCUE:** the model advertises the homotopy - (`supports_yield_homotopy` / `_yield_homotopy_control`) and - `stokes.solve(homotopy=True, homotopy_options=...)` runs the residual-guided - continuation, returning the march summary. The doctrine that made this the - recommended entry point was retracted (unit-scaling error — see - `plasticity-solvers`), and its cold-start guarantee is broken (issue #473); - use it after Newton + Picard entry and grid sequencing have failed. - ---- - -## Multigrid depth — how to measure a smoother honestly - -**A two-level hierarchy is a coarse-grid correction, not a V-cycle.** Smoother -comparisons made on one are misleading: the gmres-over-richardson margin measured on -the Spiegelman notch is only 5 % at 3 levels but **25 % at 4** (ρ per V-cycle 0.746 → -0.560), because a deeper cycle applies the smoother on more coarse operators. Judge a -smoother at depth or not at all. - -To get depth without a monster problem, refine a **deliberately ultra-coarse NESTED -base**: `make_notch_mesh.py 1` (492 cells) + uniform `refinement=N` gives 3 levels / -7,872 cells at `N=2` and 4 levels / 31,488 at `N=3` — deeper *and* smaller than the old -2-level 38,580-cell setup. In MG you want the coarsest grid as coarse as it can be -before the problem breaks down. - -- **Never use a non-nested hierarchy** here — it does not give strong MG convergence - (maintainer ruling). Uniform refinement nests by construction. -- Accepted tradeoff: uniform refinement does **not** snap new boundary nodes back to - the analytic notch arcs (no CAD/EGADS model attached), so the corner geometry is - frozen at the coarse mesh's chords on every level. - -Measure with `fmg_contraction_probe.py` (ρ_MG per V-cycle; `<0.5` healthy, `0.8–0.95` -struggling, `≥0.98` hangs) or `smoother_depth_sweep.py` (pays the mesh build + viscous -seed once, sweeps smoothers in-process) in the Spiegelman study. - -**`solve_report` cannot see the smoother.** It records the *Newton* contraction; the -outer KSP is Eisenstat–Walker-collapsed to ~1 iteration/step, so the smoother's work -hides inside the velocity sub-block. Probe the `fieldsplit_velocity_` sub-KSP directly. - -**A smoother will not rescue small ξ.** At the hard corner the failure is operator -conditioning — the coarsest grid cannot represent the viscosity contrast — and at 4 -levels *every* smoother fails there (richardson outright, gmres with ρ>1). Use the δ/ξ -continuation to stay in the solvable region. - -## FMG on an ADAPT-ON-TOP child (locally refined meshes) - -An `adapt()` child carries its **own custom-P geometric MG tail** — subsampled to -one level per **DOUBLING of h** (`mg_coarsening_ratio=2.0`, the `adapt()` default) -— on `child._custom_mg_coarse_meshes`, and solvers built on it pick it up -automatically. So the usual advice above ("never use a non-nested hierarchy") is -satisfied without you assembling anything: - -```python -child = base.adapt(metric, max_levels=3, engine="edge_split") -stokes = uw.systems.Stokes(child, velocityField=v, pressureField=p) -stokes.solve() # pc=mg auto-attached off the child's tail -``` - -Requirements and traps, all measured: - -- **Build the base with `refinement>=1` for a deeper tail.** The custom-P tail - always starts at the BASE mesh — with `refinement=0` it is - `[base] + the intermediate doubling levels`, so there IS a coarse grid — but - the uniform base levels extend it downward, and in MG you want the coarsest - grid as coarse as it can be. -- **Keep the GRADED tail.** `_adapt_nested` stores one MG level per doubling of - resolution (`_subsample_mg_levels`; per-bisection-pass levels were measured - 2.3–7.3× slower). Handing the solver a base-only tail instead — coarse base - straight to the fully adapted mesh — **triples the V-cycle count**. -- **V-cycle counts are insensitive to element quality here, and that is a PASS not - a failed measurement.** On a fault child the velocity block takes 2 iterations - (iso) or 2–3 (TI) across meshes ranging from 156° to 105° max angle. The - geometric hierarchy's coarse spaces come from the mesh hierarchy, not from the - fine operator, so shape does not move it — which is exactly what makes - adapt-on-top viable. **If you want a solver-side probe of mesh quality, use - GAMG**, which does respond (iso 79 → 64 velocity iterations with `repair=True`). - That is now actionable: `solver.preconditioner = "gamg"` is **respected** on an - adapt child (#530) — before that guard the opportunistic pickup silently - clobbered it back to `pc=mg`, so any FMG-vs-GAMG comparison was vacuous. -- **Single-field solvers get FMG too** (#478/#534): `preconditioner = "fmg"` on a - Poisson/projection-class solver builds the custom-P tail over the mesh's own - `dm_hierarchy` — the section is not Stokes-or-adapt-child only. -- **`relax()` can trip #424.** On a relaxed, unrepaired child the barycentric - transfer hit 22 zero columns and fell back to the DENSE global RBF builder — a - performance cliff, not just a warning. -- **Every PC degradation is recorded in `solver.pc_fallbacks`** (#534) — the - requested/installed/reason record for the #424 barycentric→rbf retry, a - collapsed hierarchy, a declined pickup. Read that, don't scrape warnings. -- **`repair=True` invalidates the any-degree nested transfer** (a flipped cell can - straddle two coarse cells), so degree ≥ 2 falls back to the geometric builder. - The exact ½,½ vertex prolongation survives, because flips move no vertex. -- Under **rotated free-slip** the mesh-owned adapt tail is picked up automatically - too — the rotated KSP resolves hierarchies through the same - `custom_mg.build_transfers` rule (#467 fixed the old silent GAMG fallback). See - the `adapt-on-top-faults` skill for the plain-refined-mesh case, which still - needs `set_custom_fmg`. - -Companion skills: **`adapt-on-top-faults`** (building the child, engines, repair, -band sizing), **`adaptive-meshing`** (the mover, and `relax(pin_bands=...)` for -relaxing a mesh that was refined onto an interface). - -## The Schur complement: pair the penalty with FMG, never with GAMG - -**Symptom this is for**: the velocity block's iteration count is rock solid but -the pressure sub-solve wanders into the hundreds and eventually stops -converging. - -**First: it is probably not the pressure block.** `S = -B A^-1 B^T` is applied -*through* the velocity solve, so a velocity solve that exits at its iteration -cap makes the Schur operator inconsistent between applications — and no Krylov -method converges against an operator that moves under it. The pressure block -then caps too, and the outer flounders. Measured on SolCx (eta 1e6, P2-P0disc, -h=1/30), changing **only** `fieldsplit_velocity_ksp_max_it`: - -| velocity cap | sec | outer | pressure/app | velocity/app | -|---|---|---|---|---| -| 200 (default) | 976.0 | 44 | **200.0** | **200.0** | -| 5000 | **25.6** | **2** | **30.0** | 618.0 | - -**38x from a number that is not in the pressure block**, and the velocity error -is identical in both rows. Before tuning the Schur solve, check whether either -block sat at exactly its cap — `solve_report.sub` gives iterations and -applications per block, and a per-application count equal to the cap to the -digit is the tell. - -**Then: the penalty is the lever on the Schur count, and it needs FMG.** -`stokes.penalty = lambda` adds `lambda*mu*(div u)(div v)`, which makes the -eta-scaled mass matrix a better approximation to S. Matched on one mesh -(2592 cells), same discrete solve, only the velocity preconditioner differs: - -| lambda | velocity PC | sec | outer | Schur/app | velocity/app | velocity total | -|---|---|---|---|---|---|---| -| 0 | GAMG | 15.49 | 2 | 125.5 | 94.7 | 24802 | -| 0 | **FMG** | **3.88** | 1 | **59.0** | **8.8** | **546** | -| 10 | GAMG | 20.68 | 7 | 22.3 | **199.9 capped** | 33976 | -| 10 | **FMG** | **3.06** | 1 | **18.0** | **13.5** | **270** | - -- **With FMG, `penalty = 10` improves every axis at once**: 21% faster, Schur - count 3.3x smaller, total velocity work halved. FMG absorbs grad-div - augmentation (8.8 -> 13.5 iterations per application); GAMG does not - (94.7 -> capped). -- **With GAMG, do not use it at all.** The same `penalty = 10` makes the solve - *slower* (15.49 -> 20.68 s), because augmentation is exactly what drives GAMG - into its cap. Uncapping rescues it to 11.03 s but it still needs **833** - iterations per application, and FMG is 3.6x faster on the same mesh. - Feasible is not competitive. - -**The accuracy cost is consistent, so it is safe to pair by default.** The -penalty is grad-div, not a true augmented Lagrangian — `div(P2)` is not inside -`P0`, so the term does not vanish at the discrete solution and it does perturb -the answer. But the perturbation converges away: same rate, and the gap shrinks -under refinement. - -| cells | lambda=0 v err | rate | lambda=10 v err | rate | gap | -|---|---|---|---|---|---| -| 648 | 2.112e-1 | — | 2.327e-1 | — | 1.102 | -| 2592 | 1.266e-1 | 1.67 | 1.376e-1 | 1.69 | 1.087 | -| 10368 | 8.727e-2 | 1.45 | 9.305e-2 | 1.48 | **1.066** | - -For a pressure-dependent constitutive law use the mechanical pressure, -`p_mech = p - lambda*mu*(div u)`; the raw `p` is the multiplier. - -**Traps.** - -- **FMG needs a refined base or you silently get GAMG.** Measured: - `refinement=0` -> one hierarchy level -> default velocity PC is `gamg`; - `refinement=2` -> `mg`. So `penalty` set "with FMG" on an unrefined mesh is - actually the harmful GAMG pairing. Check - `snes.getKSP().getPC().getFieldSplitSubKSP()[0].getPC().getType()`, or read - `solver.pc_fallbacks`. -- **Scaling `saddle_preconditioner` by a constant does nothing** — it does not - change the Krylov subspace. `1/eta` and `101/eta` both give 28 iterations, - identical to every digit, so an "AL-matched" `1/(eta*(1+lambda))` cannot help. - The 1/eta *weighting* itself is worth 1.9x (28 vs 52 with a flat `1`). -- **Eisenstat-Walker is inert under `snes_type=ksponly`** — identical iterations - and error on or off. And `outer 1` is not an EW artefact: it is what a full - Schur factorisation gives when the Schur complement is solved well. -- Measurements: `~/+Simulations/pressure_schur_625/` (#625). - -## Gotchas - -- **`./uw build` → `amr-dev` env**; verify `uw.__file__` is the worktree site-packages. -- **Run VEP/consistent-Newton tests UNFORKED** — `pytest --forked` SIGABRTs (fork of - multithreaded PETSc). -- Benchmark **every** default change — "Solver Stability is Paramount". -- ξ (rate-strengthening) is a **non-homotopic** regularisation: put a user loop - *around* `solve()`, never inside the δ-march. - -## Reference - -- Design: `docs/developer/design/nonlinear-solver-homotopy-warmstart.md`, - `jacobian-consistent-tangent.md`, `solver-strategies-catalogue.md`. -- Continuation driver: `underworld3.systems.yield_continuation`. -- Diagnostics: `SNES_*.get_snes_diagnostics()` / `solve_with_diagnostics()`. -- Related skills: `plasticity-solvers` (yield law + tangent per model), - `free-surface-convection`, `adaptive-meshing` (mover + `relax(pin_bands=...)`), - `adapt-on-top-faults` (locally refined children and their MG tail). -- Reconnection / refinement engines: - `docs/developer/design/mesh-reconnection-and-delaunay-adapt.md`. diff --git a/.claude/skills/nonlinear-solver/SKILL.md b/.claude/skills/nonlinear-solver/SKILL.md new file mode 120000 index 000000000..2c57852aa --- /dev/null +++ b/.claude/skills/nonlinear-solver/SKILL.md @@ -0,0 +1 @@ +../../../docs/developer/guides/nonlinear-solver.md \ No newline at end of file diff --git a/.claude/skills/plasticity-solvers/SKILL.md b/.claude/skills/plasticity-solvers/SKILL.md deleted file mode 100644 index d5f83bfd0..000000000 --- a/.claude/skills/plasticity-solvers/SKILL.md +++ /dev/null @@ -1,226 +0,0 @@ ---- -name: plasticity-solvers -description: How to get hard-Min viscoplastic / visco-elastic-plastic (VEP) Stokes solves to CONVERGE in Underworld3 — Newton with the automatic Picard entry (solver.consistent_jacobian), which tangent per model, grid sequencing for the hard cases, and the δ-soft-min substrate (yield_mode / yield_smoother / yield_anchor) as a modelling choice. Reach for THIS first when a Drucker-Prager / yield-stress Stokes solve stalls, diverges (DIVERGED_LINEAR_SOLVE / line-search fail), or grinds through ~20+ nonlinear iterations. Tells you which tangent to use per model, how to confirm you are actually running Newton, and the measured failure modes. For the solver-config trap list and multigrid, see `nonlinear-solver`. ---- - -# plasticity-solvers - -The workable recipe for **nonlinear convergence of yielding (viscoplastic / VEP) -Stokes** in Underworld3. Hard-`Min` yield laws have a non-differentiable kink that -breaks naive solvers; this encodes what the yield campaigns measured actually works — -and records what was retired. - -**The default call is now just:** - -```python -stokes.constitutive_model = cm # ViscoPlastic / ViscoElasticPlastic / TI-VEP -cm.Parameters.yield_stress = tau_y # finite -> plasticity active -stokes.consistent_jacobian = True # Newton tangent (non-elastic DP; see table) -stokes.solve() # cold start takes ONE Picard step automatically -``` - ---- - -## The doctrine (measured, 2026-07 campaigns) - -Yielding viscoplasticity is `η_eff = Min(η_visc, η_yield)`, -`η_yield = τ_y/(2·ε̇_II)`. The `Min` kink is what makes it hard. - -1. **Picard is an ENTRY requirement, not an accelerator.** On a cold start under - the consistent tangent, `solve()` takes one automatic Picard (frozen-tangent) - step and then runs Newton (fires only when `picard==0`, - `consistent_jacobian is True`, and the start is cold — from a warm iterate, - 0 Picard really is 0). Do NOT front-load Picard where Newton works: measured, - opening with 5 / 25 Picard steps cost 12 / 30 total iterations against pure - Newton's 7. - -2. **Newton-first; spend Picard only to rescue.** When Newton fails - (`DIVERGED_LINE_SEARCH` / `DIVERGED_LINEAR_SOLVE`) **or stalls admissibly** - (steps accepted, residual flat — no FAIL reason ever fires), revert to the best - iterate and buy a Picard block: `solve(picard=N)`, or - `consistent_jacobian="continuation"` (staged Picard→Newton α-blend; α is a - `constants[]` atom, no recompile). Rescue on *stagnation*, not only on a - failure reason — the failure-only trigger measured byte-identical to doing - nothing at the cliff. - -3. **Grid sequencing is the validated warm start for hard problems.** Solve - coarse (it finds the localisation structure cheaply), transfer the state up - (the linear-exact local RBF, #430), warm-start the fine solve. Measured on the - notch: 2–3× deeper residual, more localised, fewer iterations than any cold - fine strategy. No packaged API yet — hand-roll the cascade with - `uw.function.evaluate` per level; PETSc's `-snes_grid_sequence` does NOT work - on UW3 meshes. See `docs/developer/design/multilevel-nonlinear-stokes-strategy.md`. - -4. **`solver.has_solution` / tri-state `zero_init_guess`** make warm-started - campaigns safe: `None` (default) auto-detects; a diverged solve or a remesh - clears the flag so the next solve cold-starts (with its Picard entry) instead - of warming off a corrupted iterate. - ---- - -## The retired doctrine — do not resurrect it - -An earlier line of work paired the δ-soft-min with a **yield homotopy** and shipped -a model-level enable method for an in-SNES δ-ramp. That API **has been removed from -the source**, and the doctrine it taught rested on a unit-scaling error in the -campaign that motivated it. -Re-measured on the correctly-scaled problem (13 points across two parameter axes): - -- the δ-march **never succeeded where a direct hard-Min solve failed**, and where - both work the direct solve is 4–5× faster with better residuals; -- homotopy rescues **Picard**, not Newton — under the consistent tangent it adds - nothing; -- ramping δ **inside** a single SNES solve is separately proven dead (diverges - `DIVERGED_LINEAR_SOLVE` within ~2 iterations even on the proven config). - -The ruling that closed the campaign: **regularise the PROBLEM (give the shear band -a physical length scale), not the solver.** Where a hard-Min solve will not -converge, sharpening δ is not the missing lever — a viscous seed, the Picard -rescue, and grid sequencing are. - ---- - -## Which tangent for which model (measured) - -`solver.consistent_jacobian` takes `False` | `True` | `"continuation"`: - -| Model | Use | Why | -|-------|-----|-----| -| `ViscoPlasticFlowModel` (non-elastic) | **`True`** (Newton) | Quadratic near the solution; the automatic Picard entry handles the cold start. | -| `ViscoElasticPlasticFlowModel` (VEP) | **`False`** (Picard) | The consistent yield tangent over the elastic stress-history block makes the Jacobian **indefinite → `DIVERGED_LINEAR_SOLVE`**. Picard is contractive. | -| `TransverseIsotropicVEPFlowModel` (TI-VEP) | **`False`** (Picard) | Same as VEP (elastic). | -| Any, far from the solution | **`"continuation"`** | Staged Picard→Newton; Picard locates the basin, Newton finishes. Beat pure Newton at every notch point measured — but its stage switch is a residual LEVEL and one-way, so it can overspend Picard on easy problems. | - -> Measured: VEP loading-through-yield — Picard converges (σ locks at τ_y), -> Newton diverges every step (`DIVERGED_LINEAR_SOLVE`). - ---- - -## Confirm you are actually running Newton - -A consistent-Newton solve on a smooth-enough problem converges **quadratically** — -the residual roughly squares each iteration and reaches ~1e-12 in 3–6 nonlinear -steps. A **linear** tail (a roughly constant reduction factor over ~15–25 steps) -means you are on the Picard tangent — check `solver.consistent_jacobian is True` -and that the viscosity is a function of the unknowns, not a constant. (On genuinely -hard localising problems the quadratic phase may never be reached — that is the -problem, not the tangent; see the doctrine above.) - -Direct symbolic check that the Newton term is present (`dF1/dL` differs between the -frozen and unwrapped flux by exactly the `∂η/∂(grad v)` term): - -```python -import sympy -from underworld3.function.expressions import unwrap_expression -F1 = sympy.Array(stokes.F1.sym) -L = sympy.Array(stokes.Unknowns.L) -G_picard = sympy.derive_by_array(F1, L) -F1_unwrapped = sympy.Array( - [unwrap_expression(e, mode="symbolic_keep_constants") for e in F1], F1.shape) -G_newton = sympy.derive_by_array(F1_unwrapped, L) -# a nonzero difference == the Newton form is present -``` - ---- - -## δ smoothing — a modelling choice, not a convergence strategy (#475 substrate) - -If you want a *rounded* yield law at all (as physics or as a formulation choice), -the substrate is three model properties; δ is a `constants[]` atom, so changing it -never recompiles: - -- **`yield_mode`**: `"min"` (default — exact hard `Min`), `"softmin"` (the - δ-parameterised family below), `"harmonic"` (a **distinct physical model**, a - parallel blend — not an approximation to `Min`). -- **`yield_smoother`**: `"sqrt"` or `"powermean"`. **δ is NOT the same parameter - in the two families**: the power mean's sharpness is `s = 1/(δ + 0.001)`, so - δ ≤ 1 and δ = 1 IS the harmonic mean; the sqrt family's δ is a percentage stress - deviation, generous entry O(10), and δ = 0 is exactly `Min`. The power mean at - δ = 0 lands within 0.07 % of `Min` — an order of magnitude inside a 1e-8 solver - tolerance. -- **`yield_anchor`**: which point is pinned to the exact law — the SIDE of `Min` - belongs to the anchor, not the family. `"onset"` (default, historical) is exact - on the unyielded branch but sits BELOW `Min` at and above yield — a *weaker* - problem than the sharp one. `"yield"` pins τ/τ_y = 1 exactly and sits on-or-above - `Min` everywhere; the cost is stiffer unyielded material (bounded ×2 sqrt, - ×2^δ powermean, both → 1 as δ → 0). - -**If you march δ toward the sharp law, the only sound discipline is multi-solve:** -hold δ constant for a full solve to tolerance, warm-start the next smaller δ, -sharpen only between converged solves. Never ramp δ inside one SNES solve. The -packaged march is `stokes.solve(homotopy=True)` / -`underworld3.systems.yield_continuation` — usable, with two open caveats (#473): -its documented cold-start guarantee does NOT hold on a multi-material -(`Piecewise`) yield stress, so give it a viscous pre-solve anyway; and its -adaptive step control is effectively one-shot (one early decision pins the step -for the whole march). Do not expect it to cross a cliff the direct solve cannot — -measured, it never has. - ---- - -## Floors - -- **`shear_viscosity_min`** (default `-oo` = off) is applied through - `uw.maths.smooth_max`, but the default rounding scale is zero under - `yield_mode="min"` and `δ·|floor|` under the smooth modes — so it **vanishes as - δ → 0**, leaving an exact `Max` corner that kills the consistent tangent - (`nl=0, DIVERGED_LINEAR_SOLVE`). Set **`viscosity_min_rounding`** (a few per - cent of the floor) and the cutoff is differentiable at any δ, including 0. -- A viscosity floor bounds the viscosity contrast and therefore how localised the - solution can be — relaxing it toward zero is a solution-SELECTION continuation, - independent of δ. Use it deliberately. -- **Do not add a strain-rate floor for the cold start.** At `ε̇=0`, `η_pl=+inf` - is carried correctly to the viscous branch by `Min` and by both smooth families; - only a hand-rolled product-over-sum harmonic blend breaks (`inf/inf`) — write it - as `η_ve/(1+f)`. - ---- - -## Failure modes → fixes - -| Symptom | Cause | Fix | -|---------|-------|-----| -| `DIVERGED_LINEAR_SOLVE`, 0 iters, VEP | consistent Newton over the elastic block → indefinite | Picard (`consistent_jacobian=False`) | -| `DIVERGED_LINEAR_SOLVE` at nl=0 with a viscosity floor set | δ→0 leaves the floor's `Max` corner exact | set `viscosity_min_rounding` | -| Newton stalls with no divergence reason | admissible uselessness — steps accepted, residual flat | revert to best iterate, Picard block (`picard=N` / `"continuation"`); consider grid sequencing | -| Converges but σ sits **below** τ_y | a fixed δ>0 soft-min under the default `"onset"` anchor is a WEAKER law | that is the modelling choice you made — use `yield_anchor="yield"`, or δ→0 / `yield_mode="min"` for the exact surface | -| Linear (~20-iter) convergence | Picard tangent when you wanted Newton | `consistent_jacobian=True` on a non-elastic model (see "Confirm" above) | - ---- - -## Gotchas - -- **`./uw build` → `amr-dev` env.** Verify `uw.__file__` is the worktree site-packages. -- **Run VEP tests UNFORKED** — `pytest --forked` SIGABRTs here (fork of multithreaded PETSc). -- `harmonic` yield mode is a **distinct physical model**, not an approximation to Min. -- If you project η, use a **low-order** field (P0/P1) — higher order overshoots and η - is not guaranteed positive. - ---- - -## Reference - -- Yield law: `ViscousFlowModel._combine_yield`, `yield_anchor`, `yield_smoother`, - `viscosity_min_rounding` in `constitutive_models.py`. -- Tangent: `solver.consistent_jacobian` / `_jacobian_source` in - `petsc_generic_snes_solvers.pyx`; design - `docs/developer/design/jacobian-consistent-tangent.md`. -- Warm start / continuation: `docs/developer/design/nonlinear-solver-homotopy-warmstart.md`; - grid sequencing: `docs/developer/design/multilevel-nonlinear-stokes-strategy.md`. -- Tests: `test_0201_solver_has_solution_warmstart.py`, `test_1055_yield_smoother.py`, - `test_1057_yield_homotopy_solve.py`, `test_1059_yield_anchor.py`. -- Solver-config traps, smoother, FMG/multigrid: the `nonlinear-solver` skill. - -Footnote: before this work UW3 differentiated the flux with the viscosity still -wrapped, so `∂η/∂(grad v)` was dropped and viscoplastic solves silently ran the Picard -tangent — the origin of the "~20 iterations is intrinsic" folklore. - -## SNESFAS — do not reach for it - -Nonlinear multigrid (SNESFAS) looks tempting for hard viscoplastic solves but is -**not a viable option** at present (maintainer ruling 2026-07-17): there are no -good preconditioners for the nonlinear hierarchy, and it abandons the robust -linear-solver path (consistent tangent / continuation + fieldsplit + MG) that -this skill is built around. It stays options-only for experiments; treat it as a -future investigation. See `docs/developer/design/solver-strategies-catalogue.md` -and `MULTIGRID_MINIMAL_CONTROL_2026-07.md` (ruling 6). diff --git a/.claude/skills/plasticity-solvers/SKILL.md b/.claude/skills/plasticity-solvers/SKILL.md new file mode 120000 index 000000000..73d85daee --- /dev/null +++ b/.claude/skills/plasticity-solvers/SKILL.md @@ -0,0 +1 @@ +../../../docs/developer/guides/plasticity-solvers.md \ No newline at end of file diff --git a/.claude/skills/uw-visualisation/SKILL.md b/.claude/skills/uw-visualisation/SKILL.md deleted file mode 100644 index 1a43f6ea5..000000000 --- a/.claude/skills/uw-visualisation/SKILL.md +++ /dev/null @@ -1,125 +0,0 @@ ---- -name: uw-visualisation -description: Render Underworld3 mesh fields (T, V, viscosity, the adapted mesh) correctly with PyVista. Use whenever you need to SEE a UW3 result — a field colormap, the moving/adapted mesh, streamlines, or compare runs. Reach for THIS before hand-rolling a renderer; getting the four cosmetic settings wrong makes renders look grey/patchy/blocky and wastes a round-trip with Louis. ---- - -# uw-visualisation - -Canonical PyVista recipe for Underworld3 fields. This exists because every fresh -Claude session re-derives the renderer and gets the colormap / background / -lighting / DOF-sampling wrong, producing "grey/patchy/weird" images Louis -rejects. The settings below match his reference renders exactly. - -**Use PyVista (`underworld3.visualisation`), NOT matplotlib.** Louis reaffirmed -this even after seeing the legacy matplotlib renderer -(`scripts/fault_convection_frames.py`) — that one is NOT preferred. - -## Hard rules (artifacts + output location) - -- **Outputs go under `~/+Simulations/...`, NEVER `/tmp`** (Louis can't view /tmp - or harness task paths). Mirror the run's `--sim-dir`; write `T_.png` into - the run directory, comparison figures into the sim-dir root. -- `pv.OFF_SCREEN = True` at import; finish with `pl.screenshot(path); pl.close()`. - -## The field+mesh pattern (copy this exactly) - -```python -import numpy as np, underworld3 as uw, underworld3.visualisation as vis, pyvista as pv -pv.OFF_SCREEN = True - -mesh = uw.discretisation.Mesh(f"{label}.mesh.00000.h5") # or the live mesh -T = uw.discretisation.MeshVariable("T_v2p1", mesh, 1, degree=3, continuous=True) -T.read_timestep(label, "T_v2p1", 0, outputPath=D) # or use the live var - -pv_T = vis.meshVariable_to_pv_mesh_object(T) # Delaunay through T's OWN DOFs -pv_T.point_data["T"] = np.asarray(T.data[:, 0]) # attach DOF values DIRECTLY (P3-faithful) -edges = vis.mesh_to_pv_mesh(mesh).extract_all_edges() - -pl = pv.Plotter(off_screen=True, window_size=(1000, 1000)) -pl.set_background("white") # rule 2 -pl.add_mesh(pv_T, scalars="T", cmap="RdBu_r", clim=(0, 1), # rules 1 + clim required - show_edges=False, lighting=False) # rule 3 -pl.add_mesh(edges, color="black", line_width=0.5, lighting=False) # mesh overlay -pl.view_xy(); pl.camera.zoom(1.3) -pl.screenshot(out); pl.close() -``` - -## The four things that make renders look bad (all COSMETIC) - -1. `cmap="coolwarm"` → muddy grey-lavender midtone — this IS the "blue/grey/red" - Louis rejects. **Use `cmap="RdBu_r"`** (clean blue→white→red). -2. PyVista's default grey background bleeds through RdBu_r's white (T≈0.5) → dirty - grey. **Always `pl.set_background("white")`.** -3. Default lighting darkens the colormap. **Always `lighting=False`** on every - `add_mesh`. -4. Re-evaluating via `scalar_fn_to_pv_points` / vertex-only sampling drops the - high-order DOFs → blocky. **Attach `T.data[:,0]` directly** to the DOF-cloud - mesh from `meshVariable_to_pv_mesh_object` (it is correct for annulus/box/disc - — do NOT avoid it). `clim` MUST be passed (default `clim=""` trips `np.any`). -5. Resampling ANY field (even P1) onto a regular pixel grid via - `uw.function.evaluate` **dapples at element boundaries** — grid points that - straddle a facet get located into a neighbouring cell with slightly-off - reference coords (Louis: "artefacts across the elements", S-fault rig). - Render derived fields NODALLY on the mesh's own triangulation instead: - evaluate at `mesh_to_pv_mesh(mesh).points` (exact at vertices for P1, - whichever cell the locator picks), attach as point_data, let VTK - interpolate WITHIN elements. On a SPLIT mesh never Delaunay the DOF cloud - (it re-triangulates across the slit) — use the mesh's own cells. - -## Seeing the MESH (adaptation / moving mesh) - -The full-annulus T colormap **washes out mesh detail** — at whole-domain zoom the -grading is invisible. To judge adaptation you MUST crop: - -- Zoom the feature region with a parallel camera: - `pl.camera.parallel_projection = True; pl.camera.parallel_scale = half_width; - pl.camera.focal_point = (cx, cy, 0)`. -- For mesh-only views, drop the field and draw `edges` on white, `line_width≈0.7`. -- Real corruption vs render artifact: apparent "holes / lumps" are often a - mesh-overlay/low-res artifact. Before calling adaptation broken, CHECK the - field's value range is bounded and count folded elements (negative cell area) - programmatically — do NOT diagnose from a render alone. -- **Overlay the feature you're refining to** (a fault trace, an interface): draw it - as a red `pv.PolyData` line over the mesh. Without it you cannot tell whether the - refinement sits ON the feature or has drifted off it (a real failure mode — see - the `adaptive-meshing` skill). Read the geometry from the run manifest so any run - renders the same way. - -## Adaptive / long runs - -- **Render each checkpoint as it lands**, not just the last frame: arm a Monitor that - polls for new `run.mesh.NNNNN.{xdmf,h5}` and emits the index → render on each - event. A completion-only watch leaves you blind for a multi-hour (e.g. TI) run. -- The per-step mesh GEOMETRY must have been written (`write_timestep(..., - meshUpdates=True)`) or you'll render deformed fields on the stale step-0 mesh. - Load the per-step `run.mesh.NNNNN.h5` as the mesh, then `read_timestep` the vars. - -## Velocity - -Same pattern; use **streamlines, not glyphs**. Build a pv mesh for V, add -`pv_mesh.streamlines(...)` or evaluate V on a line seed. Magnitude with the same -white-bg / lighting=False rules. - -## Quantities to judge a convection run (not just pretty pictures) - -- `vrms` from `uw.function.evaluate(V.sym.dot(V.sym), mesh.X.coords)` → the clean - kinetic-energy indicator (more reliable than nodal boundary metrics). -- Surface heat flux Nu via `uw.maths.BdIntegral` on the Upper boundary. -- Mesh quality: fault/bulk nearest-neighbour spacing RATIO (cKDTree) for refinement; - folded-element count + min cell area for tangling. - -## Templates in this skill - -- `render_field.py` — single/`--all`-steps T+mesh render of a run directory. -- `render_field_streamlines.py` — T colormap + mesh + **V streamlines** (sparse - seeds, thin lines, short integration so weak/closed cells read clearly, not - black spiral-blobs). Use for convection. `--tag --all`. -- `zoom_compare.py` — side-by-side cropped mesh+field for N runs at one step. - -Copy these into the run's `scripts/` (or run in place), point `--sim-dir` at the -run, and adjust the field/variable names. They already encode every rule above. - -## Related memory - -`feedback_use_uw_pyvista_visualisation.md`, `feedback_pyvista_viz_pattern.md`, -`feedback_render_all_steps.md`, `project_adaptation_corruption_was_render_artifact.md`. diff --git a/.claude/skills/uw-visualisation/SKILL.md b/.claude/skills/uw-visualisation/SKILL.md new file mode 120000 index 000000000..b0ba15a52 --- /dev/null +++ b/.claude/skills/uw-visualisation/SKILL.md @@ -0,0 +1 @@ +../../../docs/developer/guides/uw-visualisation.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 9f53ad445..505231528 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,6 +103,14 @@ they are about (`mesh-adaptation-architecture.md`), never whimsical ones. ## Rulings a session needs in hand +**Capability guides are documentation, and a change to a family updates +them.** Curated guidance lives in `docs/developer/guides/` with front matter +naming the families it applies to (see the developer index, "Capability +guides"); `uw.capabilities()` and the class-level `view()` list a guide beside +its family, and the skills in `.claude/skills` are symlinks to those pages. +Touching a solver, constitutive model, history scheme or boundary mechanism +means checking every guide that names it, in the same change. + **Adversarial review before the PR opens.** Every branch gets one before it becomes a PR, and again after any substantial post-review commit. The checklist — parallel rank asymmetry, frame and unit boundaries, determinism, tests that cannot diff --git a/docs/developer/guides/adapt-on-top-faults.md b/docs/developer/guides/adapt-on-top-faults.md new file mode 100644 index 000000000..43b130479 --- /dev/null +++ b/docs/developer/guides/adapt-on-top-faults.md @@ -0,0 +1,371 @@ +--- +name: adapt-on-top-faults +description: Recipe for Underworld3 FAULT models on an NVB adapt-on-top mesh — resolve a fault Surface by LOCAL refinement (mesh.adapt(metric, max_levels=...) returns a child; NVB is the 2D default engine), drive it from the fault's EXACT signed distance, use rotated strong free-slip (composes with transverse-isotropy where Nitsche does not), recover dynamic topography from the constraint reaction, and run advection-diffusion on the adapted mesh with field transfer across re-adaptation. Reach for THIS for instantaneous/coupled fault-flow problems. For MMPDE node-movement convection use the `adaptive-meshing` skill instead; for rendering use `uw-visualisation`. +families: [Stokes] +kind: recipe +--- + +# adapt-on-top-faults + +The validated recipe for **fault problems on a locally-refined (adapt-on-top) mesh**. +Distilled from the annulus fault study (2026-07, `feature/adapt-on-top`). + +**This is the REFINEMENT paradigm**, not the mover one: +- `mesh.adapt(metric, max_levels=...)` bisects the base finest **locally** and returns + a **new child mesh** (`child.parent is mesh`). It is *adapt / re-adapt*, NOT node + movement — non-cumulative (each call re-marks from the static base). The child owns + a custom-P geometric-MG (FMG) tail so solvers on it get multigrid for free. +- For **MMPDE / equidistribution node movement** (deforming the same mesh to a field) + use the **`adaptive-meshing`** skill instead. Different tool; don't mix them up. + +Reference implementations (copy from these — all run np1/2/4): +`~/+Simulations/nvb_parallel_fault_study/` (weak-fault Stokes + FMG), +`~/+Simulations/shear_box_fault_study/` (iso vs TI, orientation sweeps), +`~/+Simulations/annulus_fault_study/` (rotated free-slip + topography + moving fault + +advection-diffusion). Companion skills: `uw-visualisation`, `adaptive-meshing`. + +--- + +## The core loop (fault → metric → adapt → child) + +```python +import underworld3 as uw, numpy as np, sympy + +# Base mesh MUST be built with refinement>=1 (supplies the coarse MG tail that NVB +# extends). Base cellSize ~ 2x the target h_near is the SWEET SPOT (see gotchas). +base = uw.meshing.Annulus(radiusInner=0.55, radiusOuter=1.0, cellSize=0.08, + refinement=1, qdegree=3) # or UnstructuredSimplexBox(..., refinement=2) + +# fault as a Surface (polyline control points, N x 3 with z=0 in 2D) +fault = uw.meshing.Surface("fault", base, fault_pts, symbol="F") +fault.discretize() + +# METRIC = a CALLABLE built from the fault's EXACT signed distance. This is the key +# to clean, non-patchy grading: it is evaluated at each refined level's centroids, +# so it resolves itself at the new resolution (no P1-field aliasing). +metric = fault.refinement_metric_function(h_near=0.02, h_far=0.08, width=0.05, + profile="linear") +child = base.adapt(metric, max_levels=3) # -> graded child (NVB is the 2D default) +``` + +- NVB (the 2D default engine) = graded newest-vertex bisection (bounded closure, + parallel via the native `uwnvb` transform; bit-confluent serial↔parallel); + `engine=` is the advanced selector. `engine="sbr"` = uniform + patch (the default; not graded). NVB is 2D only for now. +- `max_levels` is the isotropic-equivalent depth (NVB runs `2*max_levels` bisection + passes). The metric shape decides the grading; `max_levels` just caps it. + +### Metric options (all accepted by `adapt`) +1. **callable** `metric(centroids)->M` — **preferred for faults**. Evaluated per level. +2. MeshVariable / sympy expression — sampled via `uw.function.evaluate` from the BASE + mesh → a peaked `M=1/h²` aliases → *patchy* levels. Avoid for thin features. + +**Custom refinement shape** — pass any callable. For a *fat, uniformly-fine* band +(not just a thin line at the fault), a flat-core metric: +```python +def metric(pts, _f=fault): + d = _f.unsigned_distance(pts) # EXACT distance at arbitrary points + core, ramp, hn, hf = 0.02, 0.05, 0.01, 0.08 + h = np.where(d < core, hn, np.minimum(hn + (hf-hn)*(d-core)/ramp, hf)) + return 1.0 / h**2 +``` + +`Surface` distance API (all exact, arbitrary query points): +`fault.signed_distance(coords)`, `fault.unsigned_distance(coords)`, +`fault.director` (unit normal = normalised ∇(signed distance); the TI weak-plane +director), `fault.refinement_metric_function(...)`. + +--- + +## Engines: `nvb` vs `edge_split` — and what runs in parallel + +`engine="nvb"` (default) is graded newest-vertex bisection: bounded conforming +closure, a similarity-class bound that keeps child quality tied to the base, and +**partition-independent** output. `engine="edge_split"` splits the **longest edge** +of every cell coarser than the metric asks for and needs **no conforming closure +at all**, because splitting an edge divides every incident cell at the same new +vertex. Consequences: + +- refinement **cannot escape the marked region** — the band hugs the feature + instead of a halo around it; +- it marks on the cell **DIAMETER**, not `(dim!·vol)^(1/dim)`. The volume proxy + reported the target met while the mesh was **3.2× coarser** across the feature; +- it gives up the similarity-class bound, so quality at depth is not guaranteed + the way bisection's is — that is what `repair=` and `relax()` are for. + +**Both run in parallel, 2-D and 3-D, and both are bit-confluent** (identical mesh +at any communicator size). `edge_split` drives the same compiled `uwnvb_bisect` +transform as NVB, so it inherits star-forest propagation, co-partitioning, labels +and coordinates. Verified at np=1/2/3/4 up to 56k cells. + +```python +child = base.adapt(metric, max_levels=3, engine="edge_split") +child = base.adapt(metric, max_levels=3, engine="edge_split", repair=True) +``` + +`repair=True` runs a **reconnection (Lawson flip) pass** after each generation — +2-D and `edge_split` only; it raises rather than silently doing nothing otherwise. +It gates on **reducing the largest angle**, NOT on Delaunay: Delaunay maximises the +*minimum* angle while P1 interpolation depends on the *maximum* (Babuška–Aziz), and +flipping a gmsh mesh toward Delaunay was measured to RAISE the 99th-percentile max +angle 126.8° → 129.3°. gmsh optimises shape, not the empty-circle property. + +- **worth it on a POOR base** — anisotropic, graded, relaxed, or read from a file: + 99th-pct max angle 156° → 115°, slivers below q=0.1 3.84 % → 0.00 %. On a clean + gmsh base it moves 124.7° → 120.5° and the error not at all. +- ⚠️ **it gives up bit-confluence.** Which cavities may be flipped depends on where + the partitioner cut (no cavity may contain a cell incident on a shared point). + Conformity, orientation, volume, labels and the SF stay exact at every rank + count; only the choice of flips near a seam differs. Hence opt-in. +- Seam cost is small and **shrinks with resolution**: frozen repair sites 0.9–3.5 % + at 56k cells, np=2..8, halving with every halving of the target size. In a fault + band specifically, 5.5 % at np=2 and 13 % at np=4 on a 4k-cell mesh. +- ⚠️ the 99th-pct angle recovers under a frozen seam but the **absolute max does + not** — a few worst cells sit on the seam (148° vs 123° serial). +- it invalidates the cell-parent map for the any-degree MG transfer (a flipped + cell can straddle two coarse cells), so degree ≥ 2 falls back to the geometric + prolongation builder. The exact vertex prolongation survives — flips move no + vertex. + +## Relaxing an adapted fault mesh — PIN THE BAND + +`child.relax()` on a mesh refined onto an interface **makes things worse**. The +MMPDE mover optimises element shape against an equilateral reference and knows +nothing about where the material changes, so it slides the small cells that +refinement placed on the interface *off* it. Measured on a step-edged fault: +manufactured stress across the interface **+77 %**, and it stopped being confined +to the fault. Counter-intuitively it *reduces* the number of straddling cells +(1343 → 965) and is still worse, because the survivors are bigger. + +```python +child.relax(pin_bands=[fault]) # interface = the surface itself +child.relax(pin_bands=[(fault, 0.02)], pin_halo=2) # weak zone of half-width 0.02 +``` + +Leak unchanged to five decimal places (0.03075 → 0.03076), confinement preserved, +straddling count identical — while the mover still reshapes the rest of the +domain. `pin_halo` (default 1) pins extra rings; pinning only the cut cells lets +the mover pull on them from outside. `pin_bands` **merges** with the auto-pinned +boundaries, so it cannot silently release the domain edge. + +## Fault as a constitutive weak zone (iso and TI) + +The metric only needs the fault GEOMETRY (pure distance). The constitutive weak zone +needs the fault's distance/normal ON THE CHILD. Two ways: + +```python +# (A) re-home the SAME fault onto the child (cleanest; distance recomputes on child) +fault.remap_to(child) +eta = fault.influence_function(width=0.04, value_near=1e-3, value_far=1.0, + profile="smoothstep") # isotropic weak zone + +# (B) or build a child-side Surface (needed if the base fault is still in use for a +# base-mesh metric — symbol disambiguation refuses a base-mesh symbol in a child +# solver). fault_c = uw.meshing.Surface("fault_c", child, fault_pts); fault_c.discretize() +``` + +Isotropic weak zone: +```python +stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel +stokes.constitutive_model.Parameters.shear_viscosity_0 = eta # drops near fault +``` + +Transverse-isotropic weak PLANE (the physical fault; low fault-parallel shear): +```python +stokes.constitutive_model = uw.constitutive_models.TransverseIsotropicFlowModel +stokes.constitutive_model.Parameters.shear_viscosity_0 = eta_bulk # normal viscosity (constant) +stokes.constitutive_model.Parameters.shear_viscosity_1 = eta # weak near fault -> bulk far +stokes.constitutive_model.Parameters.director = fault.director # unit fault normal +``` +The **TI Jacobian is the full consistent tangent** (not isotropic + defect +correction — that framing is WRONG). The TI velocity FMG V-cycle needs a few more +iters than iso (~8 vs ~2) because of a directional near-null mode the isotropic +point-smoother doesn't damp — bounded and contrast-independent, not a bug. TI needs +~2 elements across the weak zone to resolve (iso ~1). + +--- + +## Rotated strong free-slip (the reason to use this, not Nitsche) + +Nitsche free-slip is INCOMPATIBLE with the TI model (its penalty scales by an +isotropic viscosity). Rotated strong free-slip imposes `u·n̂=0` as an ESSENTIAL +constraint in a per-node (n,t) frame → machine-zero leakage AND composes with TI. + +```python +# normal=None (the default) is measure-weighted and consistent with the assembly — +# prefer it. An analytic nhat is exact for the TRUE circle but keeps a consistency +# error against the faceted integral (#560); use it only when the constraint must +# follow the geometry rather than the mesh. +nhat = mesh.CoordinateSystem.unit_e_0 # exact radial normal (annulus/sphere) +stokes.add_rotated_freeslip_bc(0, "Upper", normal=nhat) +stokes.add_rotated_freeslip_bc(0, "Lower", normal=nhat) +stokes.petsc_use_pressure_nullspace = True # enclosed -> pressure gauge +stokes.solve() # rigid-rotation gauge auto-removed +# Convergence status: read stokes._rotated_freeslip_info = {ksp_reason, +# nonlinear_iterations, rotation_gauge_removed, reaction} — NOT s.snes.getConvergedReason() +# (the rotated solve is a manual loop, not snes.solve). Also sanity-check v·n leakage (~1e-16). +``` + +**Nonlinear rheology / warm-start / timestepping (PR #298, `feature/rotated-snes`):** +rotated free-slip now works *inside* the nonlinear iteration — a nonlinearity probe +auto-dispatches power-law / VEP / TI-with-yield models to a Newton/Picard loop +(`solve_rotated_freeslip_nonlinear`), so warm-started time loops are correct. On the +worktree state *before* #298 lands, the rotated path is a SINGLE linear solve — it +silently returns one Newton linearisation from `u=0` for a nonlinear model. If you +run nonlinear TI + timestepping with rotated free-slip, make sure #298 is in. + +**Dynamic topography** from the constraint reaction (the reason to bother): +```python +h = uw.discretisation.MeshVariable("h", mesh, 1, degree=1) +stokes.dynamic_topography("Upper", h, buoyancy_scale=rho_g) # h = -(σ_nn - mean)/ρg +xs, sig = stokes.boundary_normal_traction("Upper") # or the raw σ_nn (lumped-mass) +``` + +--- + +## FMG under rotated free-slip + +`rotated_bc.solve_rotated_freeslip` builds its OWN fieldsplit KSP, but it resolves +the multigrid hierarchy through the same `custom_mg.build_transfers` rule as the +standard path: an explicit `set_custom_fmg` registration wins, otherwise a +mesh-owned adapt tail is picked up opportunistically. So: +- On an `adapt()` **child**, the mesh-owned custom-P tail is auto-picked-up for the + velocity block → FMG for free, **rotated free-slip included** (the old + unreachability — rotated solves silently falling back to GAMG on adapt + children — was #467, fixed). +- On a plain **refined `Annulus`** with **rotated** free-slip, the native + `dm_hierarchy` is still not read; to get FMG you must build a coarse-mesh tail + and call: + ```python + from underworld3.utilities.custom_mg import set_custom_fmg + set_custom_fmg(stokes, [Annulus(cs=0.16), Annulus(cs=0.08)], field_id=0) # velocity block + ``` + (~4 velocity iters vs ~26 GAMG). Otherwise it falls back to GAMG (fine, just slower). + +The default velocity-block preconditioner and the pressure Schur (`1/η`) are already +near-optimal for TI — do **not** hand-roll a "TI-aware Schur"; measured, the default +`1/η₀` beats every alternative (the weak TI mode is fault-parallel shear, which is +volume-preserving, so pressure sees η₀). + +--- + +## Moving fault + re-adaptation + field transfer + +`adapt()` is non-cumulative and deterministic. Move the fault, re-adapt from the +static base, carry any field by interpolation: + +```python +for step in range(N): + fault_pts = move(fault_pts, step) # kinematics + fault = uw.meshing.Surface(f"fault{step}", base, fault_pts, symbol="F"); fault.discretize() + child = base.adapt(fault.refinement_metric_function(...), max_levels=3) + # verify: folded=0 (all cell |vol|>0), base unchanged (non-cumulative) + # carry a field child_{k-1} -> child_k by interpolation: + T = uw.discretisation.MeshVariable(f"T{step}", child, 1, degree=1) + if T_prev is None: + T.data[:,0] = uw.function.evaluate(T0_expr, T.coords) + else: + T.data[:,0] = uw.function.evaluate(T_prev.sym, T.coords) # mesh->mesh interp + T_prev = T +``` +Transfer error is **non-accumulating** for a smooth field (bounded by the per-mesh P1 +representation floor, ~0.2% on a fine base, ~3% on a coarse base) — repeated +re-meshing does not diffuse a smooth field away. Sharp fronts lose more per transfer. + +--- + +## Advection-diffusion on the adapted mesh + +```python +T = uw.discretisation.MeshVariable("T", child, 1, degree=2) +adv = uw.systems.AdvDiffusionSLCN(child, u_Field=T, V_fn=stokes.u.sym, order=1, + monotone_mode="clamp") # clamp = bounded, no overshoot +adv.constitutive_model = uw.constitutive_models.DiffusionModel +adv.constitutive_model.Parameters.diffusivity = 2e-4 +adv.f = 0.0 +dt = 0.015 # FIXED dt — SLCN is semi-Lagrangian (unconditionally + # stable). estimate_dt() reports the tiny fault-band + # Courant limit; do NOT use it to size the step. +for step in range(nsteps): + adv.solve(timestep=dt) +``` +- **SLCN dt is NOT Courant-limited** by the fine fault-band cells — pick dt from the + coarse-region advection, verify accuracy. +- The scalar AD auto-FMG-injection bug (velocity-block custom-P mismatching a scalar + operator on adapt children → PtAP error 60) is **FIXED**: `auto_inject_custom_mg` + now calls `snes.setUp()` before reading the finest reduced map (so the DM section + is the finalized space the operator lives on) and validates that map against the + assembled operator. Custom-P installs successfully on scalar SLCN AdvDiffusion on + NVB adapt children (no skip-guard, no workaround) — `bugfix/custom-mg-parallel`. + +--- + +## Sizing the band, and how the fault margin is represented + +The artefact that matters for a fault is **stress manufactured by elements that +straddle the weak-zone margin** — high strain rate at one end, high viscosity at +the other. It is exactly + +```python +leak = 2 * (eta.mean(axis=1) * edot.mean(axis=1) - (eta * edot).mean(axis=1)) +``` + +per cell (vertex values), i.e. `−2 Cov(η, ε̇)`: **zero** for any cell wholly inside +or wholly outside the weak zone, positive only across the transition. It lives +strictly *inside* elements — plotting nodal `2ηε̇` cannot show it, because at a node +the two fields are sampled at the same point and are consistent by construction. + +Measured guidance, all at matched cell count: + +- **Band width.** The answer depends on what you are minimising, and the two + objectives disagree. *Total* leak: narrower is better (concentrate cells where + ∇η is steepest). Leak **into the matrix** (what usually matters): an optimum at + core half-width ≈ the **influence width**, 2.6× better than a narrow band. + Straddling-cell count: wider is monotonically better. +- **Don't invent a marking rule.** Marking on within-cell η variation is + intuitive and measurably *worse* per DOF than the plain distance size field: + N^-0.37 (absolute jump) or a complete stall (log ratio) against **N^-1.04**. The + leak is spread across the whole transition, not concentrated in a few cells, so + there is nothing for a targeting rule to target. ⚠️ The log ratio is largest + where η is *smallest* — it refines the fault core, the opposite end from the + problem. +- **A step-edged margin confines it.** `influence_function(profile="step")` (the + DEFAULT profile) plus marking on the distance level set puts essentially **0 %** + of the leak beyond d=0.03, against 11.4 % for a smooth blend, and converges + slightly faster (N^-1.32). The price: total leak 2.5× higher and the **worst + single cell 20× worse** (21.4 vs 1.04) — concentrated into a one-cell collar + welded to the interface rather than spread. For a viscous solve that is a clear + win; for a yielding model the worst cell is what reaches yield first, so weigh + it. Mark geometrically on the level set: once the edge is sharp, sampled η + depends on which side a vertex happens to fall. +- **Exact fixes.** An element-wise constant (P0) viscosity makes `Cov(η, ε̇) ≡ 0` + on any mesh — not reduced, zero. So does aligning the interface with element + boundaries — and there are now primitives that do exactly that: `place_sheet` / + `place_thin_volume` / `remove_embedded` in `utilities/place_surface.py` + (#517–#526). Both fixes move the error from *inside* elements to *where the + element boundaries fall*, which makes `relax(pin_bands=...)` the lever rather + than shape repair. ⚠️ P0 also breaks any within-cell marking rule (contrast is + identically zero) — it would have to be reposed on the facet jump. + +## Gotchas / rough edges (candidates to fix as we go) + +| symptom / edge | cause & handling | +|---|---| +| patchy along-fault refinement (level 4 here, 2 there) | P1-interpolated `M=1/h²` aliasing. Use the **callable exact-distance** metric. | +| child solver rejects `fault.distance.sym` (foreign-mesh error) | symbol disambiguation. `fault.remap_to(child)` or build a child-side `Surface`. **Rough edge**: needing two Surface objects. | +| adapt added-cell count jitters ±30% step-to-step | small-number geometry on a thin band. Deterministic; quality (near-fault h) is constant. Base ≈ **2× target** minimises it (CoV ~10% at 0.08 base vs 19% at 0.14, 24% at 0.05). | +| tiny AD timestep / slow advection | `estimate_dt` returns the fault-band Courant limit. Use a fixed dt (SLCN is unconditionally stable). **Rough edge**: `estimate_dt` not adapt-aware. | +| surface-breaking fault: huge local vmax; topo peak keeps growing | real stress singularity at the outcrop. Topo peak **saturates** (integrable/log, bounded by finite buoyancy) — far field is fine; only the pointwise outcrop value is mesh-dependent. | +| rotated free-slip Stokes "not converged" | `s.snes` isn't the solving object (manual loop). Read `stokes._rotated_freeslip_info['ksp_reason']` / `['nonlinear_iterations']` (PR #298); sanity-check v·n leakage. | +| nonlinear TI/VEP + rotated free-slip + timestepping gives a wrong (frozen) answer | pre-#298 the rotated path is ONE linear solve (one Newton step from u=0). PR #298 runs it inside a Newton/Picard loop — ensure it's merged for nonlinear/warm-start runs. | +| FMG under rotated free-slip on a plain (non-adapt) refined mesh | the rotated KSP resolves hierarchies via `custom_mg.build_transfers`: an adapt child's mesh-owned tail is picked up AUTOMATICALLY (#467 fixed the old silent GAMG fallback), but the native `dm_hierarchy` is still not read — on a plain refined mesh, `set_custom_fmg(..., field_id=0)`. | +| NVB at np>1 raises NotImplementedError | native `_nvb_transform` extension not built (needs the custom-PETSc/amr env). Both `nvb` and `edge_split` are otherwise fully parallel, 2-D and 3-D. | +| high stress appears in the matrix beside the fault | elements STRADDLING the weak-zone margin: one end sees high strain rate, the other high viscosity. The FE forms `mean(η)·mean(ε̇)`; the honest cell average is `mean(η ε̇)`, and the difference is `−2 Cov(η, ε̇)` across the cell. Zero for any cell wholly in or wholly out. See the band-width section below. | +| scattered 1-level refinement across the WHOLE domain; far-field quality drops | the metric's far clip ``h_far`` sits below the base mesh's cell DIAMETERS (gmsh ``cellSize`` is a target edge length; diameters run 1.2–2.5x it — measured 0.108–0.223 for cellSize 0.18+ref 1). ``edge_split`` marks on diameter, so 100% of the domain refines once and the unrequested bisection DE-CONDITIONS the grid (far-field median q 0.372 -> 0.295, measured). Set ``h_far >= 1.05 * cell_diameters(base.dm).max()``. | +| refinement band narrower than the fault's INFLUENCE | measured: η still 0.07 at d=0.06 while the mesh has already coarsened 4×, so the artefact peaks on the transition flank, not on the fault. **85 % of it sits at d>0.01.** Size the flat core from the *influence* width, not the fault. | +| `uw.function.evaluate` fails "Total components 8 != 6" | cached-interpolation mismatch on a mesh already carrying several solver variables. Sample the field numerically from `surface.unsigned_distance` instead. | +| bare SIGSEGV, no traceback, after building a Mesh from a raw DM | `uw.discretisation.Mesh(dm, ...)` TAKES THE DM OVER. Read geometry from `child.dm`, never the handle you passed in. | +| `KeyError: 'Left'` from a Mesh built on a refined DM | `Mesh(dm)` without `boundaries=` loses the boundary ENUM even though the labels are on the DM. Pass `boundaries=base.boundaries`. | + +**Build/run**: this lives on the `feature/adapt-on-top` worktree; env +`.pixi/envs/amr-dev/bin/{python,mpirun}`; `./uw build` after source changes. diff --git a/docs/developer/guides/adaptive-meshing.md b/docs/developer/guides/adaptive-meshing.md new file mode 100644 index 000000000..4738d3b37 --- /dev/null +++ b/docs/developer/guides/adaptive-meshing.md @@ -0,0 +1,407 @@ +--- +name: adaptive-meshing +description: The canonical, workable recipe for Underworld3 moving-mesh / adaptive-mesh convection (annulus stagnant-lid, faults, free surface). Reach for THIS first when setting up any model with a deforming or adapted mesh — it encodes the combination that does not blow up, tangle, or inject spurious energy, and explains the failure modes so you don't re-derive them. Use before choosing movers, free-slip BCs, restart, or field-transfer options. +families: [AdvDiffusion, Stokes] +kind: recipe +--- + +# adaptive-meshing + +The one workable combination for UW3 moving/adaptive-mesh convection, distilled +from many sessions that each re-picked options and stomped on each other's +defaults. **Start from this recipe; change one thing at a time and verify.** + +Reference implementation (current, validated): the **`underworld3.workflows` +adaptive-convection example** — +`docs/examples/workflows/adaptive_convection/` on the `feature/adaptive-convection` +worktree (`config.py`+`simulate.py` no-fault; `fault_config.py`+`fault_simulate.py` +fault; `diagnostics.py`, `render.py`, `compare.py`). Express adaptive runs as a +WORKFLOW (a `WorkflowConfig` + `@workflow_step` DAG + `Run`), NOT a monolithic +driver. The older `scripts/fault_convection_adapt_loop.py` (feature/fault-convection) +is superseded — its ideas are folded into the workflow + this skill. +Companion: the `uw-visualisation` skill for rendering results. + +**Choosing the paradigm:** THIS skill is the **mover** (node movement / +equidistribution, `smooth_mesh_interior`) — the mesh deforms to follow a field. For +**local refinement** instead (`mesh.adapt(...)` returns a refined CHILD; a +fault resolved by a fine band + custom-P FMG + rotated free-slip + dynamic topography ++ advection-diffusion), use the **`adapt-on-top-faults`** skill. Different tools — +don't mix them. For the FMG setup that consumes an adapt child's hierarchy, see the +**`nonlinear-solver`** skill. + +--- + +## PIN THE INTERFACE when you relax a mesh that was refined onto one + +The two operations fight. The mover optimises element **shape** against an +equilateral reference and knows nothing about where the material changes, so it +slides the small cells that refinement placed on an interface *off* it. Measured +on a step-edged fault: manufactured stress across the interface **+77 %**, and it +stopped being confined to the fault. It even *reduces* the number of straddling +cells (1343 → 965) while making things worse, because the survivors are bigger — +leak per straddling cell up 2.5×. + +```python +child.relax(pin_bands=[fault]) # interface = the surface +child.relax(pin_bands=[(fault, 0.02)], pin_halo=2) # weak zone, half-width 0.02 +``` + +Leak unchanged to five decimals, confinement preserved, straddling count identical +— and the mover still reshapes everywhere else. Notes: + +- `pin_halo` (default 1) pins extra rings. Pinning only the cut cells lets the + mover pull on them from outside and drag the pinned ring out of shape anyway. +- `pin_bands` **merges** with `pinned_labels`. Passing `pinned_labels` yourself + REPLACES the default of "pin every named boundary", so a hand-rolled version + that substitutes the band label silently lets the mover deform the domain. +- `mesh.label_interface_band(surface, offset, halo)` is the underlying helper if + you want the label for something else. It uses the SIGNED distance at offset 0 + and the UNSIGNED distance at a non-zero offset — the unsigned distance is never + negative, so a straddle test against it at offset 0 can never fire, and a weak + zone has two margins that the unsigned form catches at once. + +--- + +## Mover quick-start (copy-paste — this is the hard-to-discover bit) + +The user entry is `uw.meshing.node_redistribution(mesh, metric, ...)` (the +purposeful spelling; it dispatches to `mesh.redistribute_nodes`, which drives +the MMPDE mover on 2D simplex meshes — `smooth_mesh_interior` is the +machinery underneath and takes the same kwargs). Minimal correct setup to +adapt a mesh to a field `T` each step: + +```python +import underworld3 as uw + +# metric from |grad T|: refinement=R is a factor on the BACKGROUND spacing h0, +# not a finest:coarsest ratio. The envelope is h in [h0/R, h0*coarsening], and +# coarsening="auto" is R**(1/d) — so R=5 in 2-D spans h0/5 to 2.2*h0, a ratio +# of R**(1+1/d) ~ 11. Use refinement=R, NOT strategy= (caps at ~2, under-grades). +rho = uw.meshing.metric_density_from_gradient( + mesh, T, refinement=5, coarsening="auto", metric_choice="front-following") + +# move the mesh — the mover (Huang-Kamenski MMPDE) is variational, +# non-folding, clusters AND aligns cells. It OWNS field transfer +# (remaps T + SLCN history, fires on_remesh hooks). +uw.meshing.node_redistribution( + mesh, rho, + method_kwargs=dict(step_frac=0.2, accel="cg", momentum=0.0), # mmpde's OWN kwargs + slip_surfaces=True, # boundary nodes slide tangentially (parallel-safe) + skip_threshold=0.9) # skip the move when the mesh is already aligned +``` + +For a **sharp feature (fault)** pass an anisotropic SPD TENSOR metric instead of +the scalar `rho` (thin ACROSS the feature normal n) and bake a gmsh base: + +```python +import sympy +n = sympy.Matrix([nx, ny]) # constant fault-normal unit vector +d = dfac.sym[0] # DIRECT unsigned distance field (P1) +M = rho * sympy.eye(2) + (Rf**2 - 1.0) * sympy.exp(-(d/w)**2) * (n * n.T) +# mesh built with: uw.meshing.Annulus(..., refine_lines=[xy], refine_size_min=smin) +uw.meshing.node_redistribution(mesh, M, + method_kwargs=dict(step_frac=0.2, accel="cg", momentum=0.0), + slip_surfaces=True, skip_threshold=None) # tensor metric: do the skip check yourself +``` + +Pitfalls that make it "not work": `method="anisotropic"`/`"ot"`/`"spring"`/`"ma"` +(RETIRED 2026-07 — they now raise ValueError; mmpde is the default and only +metric mover); injecting `relax`/`n_outer` (starves mmpde's CG); `strategy=` instead of +`refinement=R` (under-grades); a scalar bump for a fault (refines a fat corridor, +leaves the centre coarse); signed `Surface.distance.sym` for `d` (bleeds along the +line extension — use a direct unsigned distance). Full rationale + the rest of the +recipe (BCs, restart, field transfer, cadence) below. + +--- + +## The canonical recipe (defaults that work) + +### 1. Mover — mmpde +`uw.meshing.smooth_mesh_interior(mesh, metric=..., method="mmpde", +method_kwargs=dict(step_frac=0.2, accel="cg", momentum=0.0), slip_surfaces=True)`. +- mmpde = Huang–Kamenski variational, **non-folding** (energy → ∞ as detJ → 0), + clusters AND aligns cells. It is the only clean mover. +- **NOT** the `anisotropic` mover (shreds/freezes on static features) or `OT` + (slivers — OT is optimal transport, sliver-prone, not a route around the cap). +- Do **not** inject `relax`/`n_outer` into mmpde (those are the anisotropic + mover's knobs and starve mmpde's internal CG). + +### 2. Metric +- Thermal: `metric_density_from_gradient(mesh, T, refinement=R, + metric_choice="front-following")`. `refinement=R` (≈5) is the maximum local + refinement **on the background cell size h0**, not the finest:coarsest ratio: + the metric targets `h ∈ [h0/R, h0·coarsening]`, and `coarsening="auto"` takes + the budget-conserving `R**(1/d)`. So R=5 in 2-D asks for h0/5 up to 2.2·h0 — + a finest:coarsest ratio of `R**(1+1/d)` ≈ 11, and ≈ 8.5 in 3-D. Named + `strategy=` caps at ~2 and under-grades. R≈5 extracts ~all the grading the + node budget/layout allows; don't over-tune R (benign no-op above budget). + Passing `refinement` takes the **envelope branch**, which ignores `amp`, + `lo/hi_percentile`, `mode` and `power`. +- Fault / sharp feature: a **hand-built anisotropic SPD tensor** + `M = ρ·I + (Rf²−1)·exp(−(d/w)²)·n nᵀ` (thin ACROSS the feature normal n). A + scalar bump refines a fat isotropic corridor and leaves the centre-line coarse. +- Use a DIRECT unsigned distance field (geometry_tools) for `d`, NOT the Surface's + signed `.distance.sym` (its zero-contour bleeds the metric along the line + extension). + +### 3. Creation vs maintenance (the cap) — and the gmsh base COMPOUNDS +mmpde **cannot create** strong refinement from a uniform mesh — it saturates at a +fixed-topology cap (~1.8× on the fault, re-measured), because the mover has a FIXED +node budget (it redistributes, never adds nodes). To go finer you need MORE NODES, +which only gmsh can add at construction. **Bake refinement into the gmsh base** +(`Annulus(refine_lines=[xy], refine_size_min=...)` — now real, see the Faults +section) and the mover doesn't just MAINTAIN it: the extra gmsh nodes lift it off +its budget cap so it **compounds** (gmsh f2 base 0.44 → mover 0.29; f3 base 0.30 → +mover 0.19 ≈ 5× finer). Measured (Ra1e6 Rf8 res24, all folded=0): +uniform 0.55 (~1.8×) → gmsh-f2 0.29 (~3.4×) → gmsh-f3 0.19 (~5×). Judge by +fault/bulk nearest-neighbour spacing RATIO, never by global misalignment. + +### 4. Cadence — adapt as often as you like (forced every step is FINE) +Adapt every step or every few — on a CORRECT build (see §5) the mover converges +dead-flat and forced every-step adaptation under vigorous convection is stable +(validated: 39/39 forced adapts, mesh folded=0, area-ratio ~14 flat). Skipping +when aligned just saves cost. (An earlier claim that forcing adaptation +"tangles / over-injects energy" was WRONG — that was the deformed-eval bug in §5.) + +### 5. THE bug that wrecked adaptation (holes) — and the fix +SYMPTOM: giant empty cells / holes in the adapted mesh, intermittent area-ratio +spikes, convection wrecked. ROOT CAUSE (proven): `uw.function.evaluate` +**mis-locates points on a deformed mesh** (the nav kd-tree `mesh._nav_coords` was +captured from the ORIGINAL coords and never refreshed), so the metric — built with +strictly-positive nodal values — evaluates to **NEGATIVE garbage** (even at its own +DOFs) → non-SPD → the mover wrecks the mesh. FIX (both): +- **`8a9d2ff2`** (refresh `_nav_coords` + projected normals on every deform) — + the real fix; makes `function.evaluate`/`points_in_domain` track deformation. +- **Monotone RBF metric bake** (in `_mmpde_mover`, formerly `_winslow_mmpde`): Shepard-interpolate the + metric from its **positive nodal values** — a convex average is guaranteed ≥0 + (monotone) + fast (no cell-location). Use RBF for the metric; it doesn't need + high-precision eval. +With these, the metric stays positive/SPD; #259's SPD-floor never fires (harmless). +The mover, `accel`, and `refinement=R` (e.g. R=5) are all FINE — they were +red-herring symptoms of the eval bug. Always confirm a CLEAN BUILD first +(`./uw build`; `md5 site-packages/.../smoothing.py == src`); a stale build is its +own cause of holes. + +### 6. Stokes free-slip +Penalty (`add_natural_bc(KFS·v·n·n)`, KFS≈1e6) or Nitsche γ=10 both work on a +UNIFORM mesh. Nitsche preferred for sharp fault corners. Do NOT diagnose free-slip +with nodal v·n: Nitsche enforces v·n=0 weakly, so nodal v·n is large even when +correct — use vrms (kinetic energy). (An earlier "warm-restart × Nitsche → blow-up, +use cold restart" diagnosis was WRONG / confounded by the §5 eval bug + a stale build.) + +**On an ADAPTIVE mesh, Nitsche free-slip γ=10 is TOO SOFT → intermittent vrms +spikes at adapt steps** (under-enforcement, NOT a blow-up: single-step velocity +garbage that T survives because the solve is cold-restarted — e.g. vrms +144→8887→144). The mover pins the boundary and refines just beneath it, creating +high-aspect-ratio near-boundary cells whose Nitsche inverse-estimate constant needs +a bigger penalty. Two fixes, BOTH good (corroborated across sessions): +- **Nitsche γ=100** (not 10) for a free-slip top on an adaptive mesh — clean, smooth + signal. This is INDEPENDENT of the penalty's mesh-size scaling: local vs global `h` + are equivalent here (both garbage at γ=10, both clean at γ=100). +- **Don't fully pin the boundary in the mover** — let it tangential-slip + (`slip_surfaces=True`, the §1 default), NOT `pinned_labels=[Upper,...]`. Avoiding + the distorted near-boundary cells lets γ=10 work. Pin a boundary only when you must + hold a prescribed shape (e.g. a free-surface height the integrator just set). + +Aside — free-SURFACE held-lid is the OPPOSITE problem: the GLOBAL `h = +get_min_radius` drifts BELOW the surface cell as the interior refines → the Nitsche +penalty OVER-stiffens → a spurious one-step surface "mountain" (vhmax spike). Fix = +a LOCAL per-cell `h` via `mesh.cell_size()` (deformation/adaptation-tracking); +`add_nitsche_bc(..., local_h=True)` is the default (PR #275). So held-lid wants LESS +penalty (local-h), free-slip-adaptive wants MORE (γ=100) — different knobs. + +### 7. Advection + timestep +`AdvDiffusionSLCN(mesh, u_Field=T, V_fn=V.sym, theta=1.0, monotone_mode='clamp')`. +theta=1.0 (backward Euler) for stability; monotone clamp bounds SL overshoot; +`V_fn=V.sym` is the PHYSICAL velocity. **dt can be LARGE — SLCN is unconditionally +stable; the smallest/median adapted cell does NOT have to govern dt.** Use +`estimate_dt(percentile=50)` × a multiplier (e.g. dt_mult 3–5) to advance physical +time faster (needed to develop convection in stiff stagnant-lid regimes). + +### 8. Rheology +- **isotropic** (linear) ⇒ `snes_type=ksponly` (one exact KSP solve; default + newtonls rejects steps on vigorous flow). Cheap. Use for resolved features. +- **TI / anisotropic** weak fault (`TransverseIsotropicFlowModel`: + `shear_viscosity_0=η_FK`, `shear_viscosity_1=η_weak`, `director`=fault normal): + keep the **default newtonls** (NOT ksponly — ksponly converges to the WRONG + answer); use **penalty** free-slip (Nitsche trips the anisotropic-Jacobian bug); + **GAMG** (FMG ~7× slower). Measured on the gmsh-resolved + one-sided base + (Ra1e6 Δη1e3): ran clean to t=0.06, folded=0, vrms→20, **~10×** the isotropic + cost per step (GAMG eats the 1000× anisotropic contrast — well under the feared + 20×). The TI fault visibly steers the flow (persistent recirculation at the trace). +- Viscosity-bearing fields are **P0/P1 only** (positivity; higher order overshoots). + FK viscosity `η = exp(θ(1−T))`, θ = ln(Δη). Floor a weak zone, don't multiply → 0. + +### 9. Build discipline +- `./uw build` after ANY source change; verify `uw.__file__` is in site-packages + and (when debugging) that `site-packages/.../smoothing.py` matches `src/...`. A + **stale mover build is a top cause of "giant empty elements / holes"** in the + adapted mesh. +- **NEVER** `pip install -e .` (contaminates all envs). Run from inside the worktree + (`pixi run -e amr-dev`). + +--- + +## Faults — the gmsh-resolved, on-fault, one-sided recipe (validated 2026-06-23) + +The full fault recipe, hard-won. Reference implementation: the +`underworld3.workflows` adaptive-convection example +(`docs/examples/workflows/adaptive_convection/{config,fault_config}.py`, on the +`feature/adaptive-convection` worktree — built on the FIXED mover base). Use the +WORKFLOW system, not a monolithic driver. + +### gmsh line refinement (`refine_lines`) — NOW IMPLEMENTED in `Annulus` +```python +uw.meshing.Annulus(radiusOuter=1, radiusInner=0.5, cellSize=1/24, + refine_lines=[xy], # list of (N,2) polylines (model coords) + refine_size_min=cellSize/3, # cell size ON the line (factor 2-3 is plenty) + refine_dist_min=0.02, refine_dist_max=0.12) # size ramps back to cellSize +``` +A gmsh **Distance + Threshold** field along the polyline; INTERIOR points are +embedded so nodes land ON the line. Backward-compatible (default `None`). It is a +core meshing change → lands as its own small meshing PR, separate from the workflow +example. (Before this session it was only *called* in old scripts and never existed +— don't trust `refine_lines` on any branch but the one carrying this commit.) + +### Keeping refinement ON the fault (the metric-composition trap) +The fault metric is `M = ρ·I + (Rf²−1)·exp(−(d/wₙ)²)·n nᵀ` with the isotropic SIZE +density `ρ`. **Do NOT fuse the fault density into ρ_T by PRODUCT** — the cold +surface thermal BL (ρ_T ~ R^d ~ 20–25) out-competes the fault near its top and the +refinement drifts ABOVE the fault, starving the deep fault ("seems to repel"): +- Use `ρ = max(ρ_T, fault_ρ)` (NOT `ρ_T · fault_ρ`). +- Make `fault_ρ = 1 + amp·gauss` with **amp > ρ_T** (~25 at R=5) so the fault wins + the max along its whole length. +- DIAGNOSE this by comparing the gmsh BASE (step 0, refinement centered on the + fault — correct) vs the DEVELOPED mesh (drifted above) → it's the MOVER's metric, + not gmsh. Render the mesh with the **fault trace overlaid** (`render.py --fault`). + +### One-sided fault influence (the clean control) +Even with max+amp, the symmetric metric DEMANDS both flanks while realized nodes +drift to the hanging wall. Make it one-sided: +- Store the **SIGNED** distance in `dfac` (the gaussians square it, so magnitude is + unchanged — the sign only feeds a `0.5(1+tanh(m·d/w))` gate). Probe the + radially-outward side once to define "upper" regardless of the distance tool's + orientation convention. +- `fault_metric_side` (both/upper/lower) gates the refinement; `fault_rheology_side` + gates the weak zone. **`both=upper`** is the physical recipe: a one-sided + hanging-wall damage zone with the mesh refined on the same side (refinement and + rheology coincide; gmsh-f3 → fault/bulk ~0.19, folded=0). `metric_side=lower` + instead pulls refinement onto the footwall to counter the upward drift. + +### The wedge fill (anti-collision) +The fault pull and the surface-BL pull compete for the coarse cells in the radial +sliver BETWEEN them. `fault_wedge=True` gmsh-fills that wedge (sample radial +segments from each fault point up to the surface, add as a second `refine_lines` +point set) so both pulls have their own budget and merge into one coherent fine +wedge instead of colliding. + +### Weak zone (rheology) — geometric blend + gaussian PEAKED on the fault (2026-06-24) +The weak-zone viscosity blends `η_FK` (background) and `floor` (fault) by the +influence `f`∈[0,1] (P1, positive). Get it right with TWO rules (verify by +reconstructing + rendering the REALIZED η field — `render_fields.py` — and the +combined `η_FK(T)^(1−f)` field; never assume the floor is reached): +- **GEOMETRIC blend `η_weak = η_FK^(1−f)·floor^f`** (NOT arithmetic + `η_FK·(1−f)+floor·f`, whose `(1−f)` term leaks the stiff-lid background through + → η≈8 at f=0.97 in a 1000× lid). Geometric reaches the floor genuinely (η≈1.2 at + f=0.97) AND ties the contrast to the LOCAL η_FK — so the fault automatically + bites hardest where it cuts the cold stiff lid (physically correct), nothing in + the hot interior. +- **`f` must be PEAKED on the fault (gaussian), NOT a TOPHAT block.** A top-hat + makes a uniform weak BLOCK; its sharp edges mean strain follows ∇f (TWO parallel + lines at the block edges, not on the fault), and a one-sided block is offset to + the hanging wall (η_1=1 core sits ABOVE the drawn line). Use a **gaussian + `f=exp(−(d/w)²)`** (peak f=1 ON the fault → η_1=1 on the drawn line, strain + localizes INTO the slot as a single feature). side=both = symmetric; side=upper = + hanging-wall halo (gaussian taper up, sharp footwall recovery — NO halving gate). + Centre the METRIC too (`metric_side=both`) so nodes refine on the line. +THERMAL CONTRAST (Louis's insight, confirmed): even a symmetric gaussian gives a +much bigger η-contrast on the COLD (upper/surface) side than the warm (footwall) +side, because η_FK rises ~1000× toward the surface — so the fault's dynamical +prominence naturally concentrates in the cold lid. This is a feature, not a bug. +Verified (gmsh-f3, gaussian width=0.025, side+metric=both): weak zone centred on +the drawn fault on the adapted mesh, folded=0. TUNE AT STEP 0 (build mesh+fields, +no solve — fast; plot the 1D η_1(d) profile + the 2D field). COST: genuine 1000× +TI contrast ~25–145 s/step (cold-start steps slow, ~25 s once developed; GAMG). +For TI see §8. (`fault_config.py`: `fault_profile=gaussian` (default still tophat — +pass gaussian), geometric blend in `create_solvers`; `render_fields.py` light maps.) + +--- + +## Failure modes — symptom → cause → fix + +| Symptom | Cause | Fix | +|---|---|---| +| Giant empty cells / holes in adapted mesh; intermittent area-ratio spikes | `function.evaluate` mis-locates on deformed mesh → metric → negative/non-SPD (the real bug). OR stale build. | `8a9d2ff2` + monotone RBF metric bake; `./uw build` + verify md5 site-packages==src | +| Decays when it should convect | over-diffusion OR under-resolution OR dt too small to develop | check a resolved arbiter (finer space+time); raise node budget; **larger dt** (dt_mult 3–5) | +| Fault won't refine under convection | mmpde creation cap; field gives no signal in cold lid | gmsh `refine_lines` base — the gmsh nodes let mmpde COMPOUND past the cap (~5×) | +| Refinement drifts ABOVE the fault / "repels", deep fault starved | fault density fused by PRODUCT with ρ_T → thermal BL out-competes the fault near the surface | `ρ = max(ρ_T, fault_ρ)`; `amp > ρ_T` (~25); or one-sided `fault_metric_side`; +`fault_wedge` | +| Refinement on both flanks but you want one side | symmetric (unsigned-distance) metric | signed `dfac` + `fault_metric_side`/`fault_rheology_side` tanh gate (`both=upper` = physical) | +| TI fault solve: ksponly gives wrong answer | ksponly skips the Picard the inexact GAMG inner solve needs | keep default newtonls; penalty free-slip; GAMG (~10× isotropic cost, fine) | +| free-slip ADAPTIVE: vrms spikes at adapt steps (e.g. 144→8887→144), T stays bounded | Nitsche γ=10 too soft on the distorted near-boundary cells the pinned-top+interior-refine creates (under-enforcement) | **Nitsche γ=100** (not 10); OR don't pin the boundary (tangential-slip `slip_surfaces=True`). h-scaling (local vs global) is moot here | +| free-SURFACE held-lid: spurious one-step surface "mountain" / vhmax spike | global `h=get_min_radius` drifts below the surface cell as interior refines → Nitsche OVER-stiff | LOCAL per-cell h: `add_nitsche_bc(local_h=True)` (default, PR #275) = `mesh.cell_size()` | + +**Diagnose by:** vrms (KE), Nu (`BdIntegral` surface flux), fault/bulk NN-spacing +RATIO (cKDTree), folded-element count + min cell area. Compare runs **at matched +physical time t** (dt differs between meshes), not by step number. When unsure +whether behaviour is physical, build a **resolved arbiter** (uniform mesh finer in +BOTH space and time) — if it agrees with one candidate, that's the truth. + +## Diagnostics (in the workflow example, reusable) +`diagnostics.py`: `mesh_quality` (folded / area-ratio / aspect), `nn_spacing_ratios` +(BL + fault/bulk), `NusseltSurface`, `vrms`, `History`. `render.py`: T+mesh+ +streamlines on the `Run` layout, **`--fault`** overlays the fault trace (read from +the run manifest) + **`--focus-fault`** auto-crops on it + **`--mesh-only`** for the +clean mesh, **`--all`** for every frame. `compare.py`/`fault_refine_plot.py`: +matched-physical-time comparison + fault/bulk-ratio time series. +**Rendering long runs as they go:** a completion-only Monitor is NOT enough — arm a +Monitor that POLLS for new `run.mesh.NNNNN.xdmf` checkpoints and emits the index, so +you render each step as it lands. +**Checkpoint an ADAPTIVE mesh with `meshUpdates=True`** (per-step geometry) or the +saved frames pair deformed fields with stale step-0 geometry. + +## The verified canonical command + +Requires the fix (§5): `8a9d2ff2` (deformed-mesh point-location) + the monotone RBF +metric bake in `smoothing.py`. Validated 2026-06-22 at vigorous Ra1e6/Δη1e3 +stagnant-lid: forced adapt EVERY step (39/39), larger dt, → vrms→18, Nu→1.86, +|v|max 61, mesh CLEAN every frame (folded=0, area-ratio ~14 flat), no abort. + +```bash +# no-fault baseline (the workflow CLI; one flag per config field) +pixi run -e amr-dev python docs/examples/workflows/adaptive_convection/simulate.py \ + --output-dir ~/+Simulations//baseline \ + --rayleigh 1e6 --delta-eta 1e3 --cellsize 0.0417 \ + --resolution-ratio 5 --adapt-every 1 --dt-mult 4 --max-steps 80 --max-t 0.06 + +# resolved fault: gmsh base (factor 3) + on-fault + one-sided hanging wall + TI +pixi run -e amr-dev python docs/examples/workflows/adaptive_convection/fault_simulate.py \ + --output-dir ~/+Simulations//fault \ + --rayleigh 1e6 --delta-eta 1e3 --cellsize 0.0417 --resolution-ratio 5 \ + --fault-base-smin 0.0139 --fault-anisotropy 8 \ + --metric-combine max --fault-refine-amp 25 \ + --fault-rheology-side upper --fault-metric-side upper \ + --rheology ti --dt-mult 4 --max-steps 40 --max-t 0.06 +``` + +Key choices, verified: +- **`--resolution-ratio 5`** (R=5) is fine — the mover handles it on a correct build. +- **`--adapt-every 1`** force adapt every step (the strongest mesh test). +- **`--dt-mult 4`** — larger dt for STABILITY is fine (SLCN unconditional); but it + costs transient ACCURACY (over-diffusive backward-Euler DELAYS the convective + onset vs a resolved arbiter — dt×1.5 recovers it). Use small dt_mult for faithful + transients, large to reach quasi-steady fast. +- **`--freeslip penalty`** (default) — REQUIRED for TI (Nitsche trips the + anisotropic-Jacobian bug). It's the raw velocity penalty `kfs·(v·n)n`. +- Fault: `--fault-base-smin` (gmsh resolve), `--metric-combine max` + + `--fault-refine-amp 25` (keep refinement ON the fault), `--fault-*-side` (one-sided), + `--rheology ti` (real fault). Drop the fault flags for the no-fault control. + +Render with `render.py` (`--fault --focus-fault` to see the trace + refinement +coincide; `--mesh-only`; `--all`). Judge the mesh by folded/area-ratio, the physics +by vrms/Nu, ALWAYS at matched physical time. + +## Related memory +`project_adaptive_convection_as_workflow` (THIS session: workflow port, gmsh +refine_lines, on-fault/one-sided/wedge, TI, dt-accuracy), `project_mmpde_holes_real_root_cause`, +`project_fault_refine_fixed_topology_cap`, `project_uw_workflow_landing`, +`feedback_debug_adaptive_solver_method`, `project_fault_convection_working_settings`. diff --git a/docs/developer/guides/adversarial-review.md b/docs/developer/guides/adversarial-review.md index c9e45368b..14a13813f 100644 --- a/docs/developer/guides/adversarial-review.md +++ b/docs/developer/guides/adversarial-review.md @@ -88,6 +88,16 @@ name. An anonymous float collapses into the assembled product and the transcript can only show the number. Examples in `docs/` name their coefficients. +**A family's guide says what the family can do now.** The curated guides in +`docs/developer/guides/` carry front matter naming the families they apply +to, and `uw.capabilities()` lists them beside each family. A change to a +solver family, a constitutive model, a history scheme or a boundary +mechanism is reviewed against every guide that names it: the guide is +updated in the same change, or the review says why it still holds. The AI +skills in `.claude/skills` are symlinks to those pages; +`tests/test_0030_capability_guides.py` fails on a copy, on a guide without +front matter, and on a family name no class carries. + ## Where the reviews live `docs/reviews/[YYYY-MM]/`, indexed by `docs/reviews/README.md`, and posted on diff --git a/docs/developer/guides/boundary-condition-rulings.md b/docs/developer/guides/boundary-condition-rulings.md new file mode 100644 index 000000000..b5b7f9605 --- /dev/null +++ b/docs/developer/guides/boundary-condition-rulings.md @@ -0,0 +1,89 @@ +--- +name: boundary-condition-rulings +description: Which boundary treatment to use in Underworld3 and why — rotated strong free-slip over Nitsche or penalty, Nitsche only for a condition that must evolve, natural tractions, essential data as controls in an adjoint, what happens at corners, and the mesh-side traps that leave a condition silently empty. Read with the class-level view of a solver, which lists the mechanisms it accepts. +families: [Stokes, Stokes_Constrained, VE_Stokes, NavierStokes, Poisson, Diffusion, AdvDiffusion, SteadyStateDarcy, TransientDarcy, Richards] +kind: guide +--- + +# Boundary conditions: the rulings + +A solver lists the mechanisms it accepts (`uw.systems.Stokes.view()`: +essential, natural, Nitsche, rotated free-slip, fault contact, and for a +saddle point the multiplier constraint). This page says which to choose, +and what each one commits you to. + +## Free-slip: rotated strong free-slip, not Nitsche or penalty + +`solver.add_rotated_freeslip_bc(conds, boundary, normal=None)`, value first: +`conds=0` is free-slip, a scalar or expression prescribes the wall-normal +datum strongly. + +- It enforces $v\cdot\hat n = 0$ to machine precision; Nitsche and penalty + leak at about $10^{-3}$. +- It is correct on curved, tilted and deformed boundaries: the normal is + taken per node, measure-weighted to match the facet integral the + assembler evaluates (#560). Leave `normal=None` unless the constraint + must follow the true surface rather than the mesh; an analytic normal is + exact for the geometry but carries a consistency error against the + faceted assembly. +- It works inside the nonlinear SNES and with geometric multigrid, and is + transparent to the tangent (`consistent_jacobian` behaves as it would + without it). +- Its reaction is the boundary normal traction, read with + `solver.boundary_normal_traction(boundary)`; no augmented-Lagrangian + splitting is involved. + +Governing document: [rotated free-slip](../subsystems/rotated-freeslip.md). + +## Nitsche: only for a condition that must evolve + +A hard rotated constraint cannot morph. A condition that changes character +in time, a Dirichlet-to-Neumann ramp or a traction that switches on, is a +Nitsche condition (`add_nitsche_bc`). Two rulings come with it: + +- The penalty scale must be recalibrated whenever the cell size is + redefined (#734). A value of 10 was a cliff, a hundred times worse than + 12.5, after the cell size changed under it; the scale is tied to the + cell size by #697. +- Nitsche is the one mechanism whose boundary Jacobian reads the unknown, + so a solver carrying it takes the matrix route for its adjoint and + refuses a parameter in a Dirichlet datum until the boundary tangent is + in the reaction term. + +## Natural conditions: tractions and fluxes + +`add_natural_bc(value, boundary)` is a facet load. `mesh.Gamma` in the +expression resolves to the facet normal: exact per quadrature point on an +external boundary, the declared analytic normal on an internal one, where +PETSc's normal is orientation-ambiguous (#327). A parameter in a natural +condition is differentiated by the adjoint as a facet part with nothing +further from the user. + +## Essential data + +`add_essential_bc` and `add_dirichlet_bc` are the same call. Components +given as `None` (or `sympy.oo`) are left free. Three things to know: + +- PETSc constrains the closure of each labelled boundary, corner vertices + included, and inserts the data boundary by boundary in the order they + were registered, so where two boundaries meet the later one's datum is + the one applied. Register the lid last if the lid's value is the one the + corner should carry. +- A named parameter in a datum is a control: `solver.gradient(...)` + carries its reaction term (#762). A bare number is not a control, since + it has no name. +- A datum given as a plain number is scaled by the model's units at + registration; a symbolic datum is scaled when it is compiled. Either way + the transcript's key shows the value as applied. + +## The mesh side: conditions that come out empty + +- Gmsh physical groups must be numbered in the order of the boundary + enumeration, or the conditions attach to the wrong stratum and are + silently empty. Check `mesh.view()`, which lists the boundaries with + their sizes at level 1. +- A patch on a fault must be at least three cells across to be split; + smaller, the split refuses. +- A boundary condition on a field that is not the solver's unknown (a + pressure datum on a Stokes solver) is accepted by the solver but not by + the adjoint, which refuses rather than drop it. diff --git a/docs/developer/guides/cetz-figures.md b/docs/developer/guides/cetz-figures.md new file mode 100644 index 000000000..18a0ec934 --- /dev/null +++ b/docs/developer/guides/cetz-figures.md @@ -0,0 +1,145 @@ +--- +name: cetz-figures +description: Build schematic / labelled-geometry figures for underworld3 papers using Typst + cetz. Use when the figure is primarily about topology, annotation, and math-typeset labels (meshes, solver diagrams, flow charts). Prefer Python → SVG → `#image()` for data-heavy figures (fields, colormaps, arrow plots) instead. +families: [] +kind: recipe +--- + +# cetz-figures + +Scaffold for Typst/cetz figures in `publications/**/figures/` alongside the +existing `arrays-sync-flow.typ`. This skill exists because upstream Claude +sessions draft cetz blind — this one actually compiles. + +## When to use cetz + +- Mesh schematics with a few labelled triangles / vertices / control points. +- Solver / data-flow diagrams (see `arrays-sync-flow.typ` in this repo). +- Anything where labels should render in the paper's math/text fonts. +- Anything that benefits from recompiling with the paper. + +## When to use something else + +- **Data-heavy plots** (scalar fields, colormaps, quiver plots, anything with + dense per-pixel or per-cell data) — generate SVG from Python/matplotlib, + include via `#image("foo.svg")`. cetz will fight you here. +- **Geometry computation** (Delaunay, intersections, interpolation) — do it in + Python offline, emit JSON with the shape `{"vertices": [...], + "triangles": [...], ...}`, let Typst just draw. + +## When NOT to use TikZ + +Evaluated and rejected: +- Slower compile than Typst. +- Drags in a LaTeX toolchain that isn't otherwise required by the project. +- No Typst math-font advantage over cetz for labels in our paper context. + +Keep TikZ in back pocket only if a co-author insists on TikZ source. + +## Before you draw (thesis-first discipline) + +When a user hands you a figure request — especially one replacing an +existing ASCII sketch, whiteboard photo, or reference figure — **do not +start transcribing**. The source artefact is a hypothesis about what +to communicate, not a specification. Before opening cetz: + +1. **State the thesis in one sentence.** What is the figure arguing? + If you can't write it plainly, you don't yet understand the figure. + Ask the user to articulate it. + +2. **Honestly audit the source.** If the original ASCII / sketch is + being replaced, it's often replaced *because it doesn't land well*. + Say what's broken about it before proposing the replacement — the + user often agrees and the new figure can do more than the old. + +3. **Enumerate design decisions as explicit questions, not assumptions.** + For a curved-boundary normals figure that's typically: + - Geometry (circle / ellipse / arc span / zoom level) + - Sampling density (number of facets, quadrature points per facet) + - Overlay vs. side-by-side + - Whether to show error quantitatively (arcs, annotations) or leave + it as a visible angle + - Where the figure lives in the repo (which doc / which branch) + Present a proposed interpretation with the decisions flagged; let + the user resolve them before you compile. + +4. **Only then open cetz.** Iterate visually — the discipline above is + about not committing to a design prematurely, not about planning + exhaustively. Once you start, compile often. + +The user's phrase "I'm not quite sure what this is intended to +illustrate" is the canonical trigger for this discipline. If you hear +it (or catch yourself about to transcribe without checking), stop and +do the four steps above. + +## Key gotchas (hit during iteration — not hypothetical) + +1. **Don't give a helper a parameter named after a `cetz.draw` export** + — `anchor`, `fill`, `stroke`. The cause is the `import cetz.draw: *` the + helper needs (gotcha 6): it runs *inside* the function body and shadows the + parameter, so the parameter name resolves to cetz's function rather than to + the value you passed. `anchor` panics with `"Unknown anchor 'anchor' for + element 'none'"`; `fill` gives `"expected color, gradient, tiling, or none, + found function"` pointing into `canvas.typ`, nowhere near your code. Rename + to `align-to`, `bg`, `edge`. See `cetz-cheatsheet.md`. + +2. **Clipping is a Typst concern, not a cetz one.** cetz has no `\clip`. + Wrap the canvas in `#box(clip: true, width: ..., height: ..., ...)` and + draw slightly oversized inside the canvas — the box clips the overflow. + +3. **Painter's algorithm — order matters.** Draw the background first, the + highlight second. No z-index exists. Verified in `mesh-demo.typ`. + +4. **Semi-transparency via `rgb(r, g, b, a)`** (alpha 0–255) or + `color.transparentize(col, 50%)`. Both work for fill and stroke. + +5. **Math in labels just works.** `content(pos, $v_1$)` renders in the + document math font. No escape hatch needed. This is a real cetz win over + SVG. + +6. **`import cetz.draw: *` inside the canvas closure.** Without it, `line`, + `circle`, `content` aren't in scope. Helper functions that draw need + their own `import cetz.draw: *` line inside. + +## Project layout pattern + +Each blog post or paper section gets its own subdirectory under +`figures/`, so a post's figures travel together: + +``` +publications/blog-posts/figures/ +└── / + ├── .typ # cetz drawing + ├── .png # committed output + ├── -data.json # (optional) precomputed geometry + └── generate-.py # (optional) Python that writes the JSON +``` + +Concrete example: `publications/blog-posts/figures/finding-particles/` +holds `mesh-demo.*` and `domain-demo.*` for the post +`finding-particles.md`. + +The JSON intermediate is the forward bridge to underworld3 — see +`underworld-bridge.md`. + +## Reference files + +- `cetz-cheatsheet.md` — what worked from memory vs. needed lookup. +- `underworld-bridge.md` — JSON schema for future `uw.meshing` export. +- `examples/` — self-contained copies (each with `.typ`, `.png`, `.json`, + and generator `.py`) of the figures this skill is scaffolded from. + These are snapshots; the live versions may have drifted if a post or + doc was iterated on further. + - `mesh-demo.*` — element-level point-in-cell test. + Live: `publications/blog-posts/figures/finding-particles/`. + - `domain-demo.*` — parallel domain centroid ambiguity. + Live: `publications/blog-posts/figures/finding-particles/`. + - `facet-vs-true-normals.*` — facet normal vs. smooth-surface normal + on a curved boundary. Live: + `docs/advanced/figures/curved-bc/`. + +## Canonical reference in the repo + +`publications/blog-posts/figures/arrays-sync-flow.typ` — prior cetz figure +in the repo, established version (0.3.4) and house style (hex colours, +helper-function pattern). Follow its conventions. diff --git a/docs/developer/guides/free-surface-convection.md b/docs/developer/guides/free-surface-convection.md new file mode 100644 index 000000000..4647fffa6 --- /dev/null +++ b/docs/developer/guides/free-surface-convection.md @@ -0,0 +1,226 @@ +--- +name: free-surface-convection +description: The Underworld3 free-surface convection method we are hardening — the THREE-NUMBER pointwise topography integrator (held-lid stress equilibrium h_∞ + L-stable exponential relaxation), NOT FSSA. Reach for THIS before touching any free-surface / dynamic-topography convection run, choosing a surface-update scheme, or "stabilising" a free surface. It records the method, why FSSA is explicitly rejected, and the failure modes. +families: [Stokes, AdvDiffusion] +kind: recipe +--- + +# free-surface-convection + +The free-surface scheme used in `~/+Simulations/FreeSurface/convection/fs4_compare.py` +and the design doc `docs/developer/design/FREESLIP_DYNAMIC_TOPOGRAPHY_FREESURFACE.md`. +**This is the method we are HARDENING — do not replace it; do not add FSSA.** + +> The 3-number integrator is necessary but NOT sufficient — see **Hardening strategies +> (2026-06)** below for the material-surface advection, tangential topography term, +> free-slip-inner nullspace, and graded/higher-order-mesh fixes that make it actually +> work. Reference impl + diagnostic tools live in `~/+Simulations/FreeSurface/convection/`. + +## ⚠️ NOT FSSA + +The `docs/examples/free_surface/advanced/Annulus*FS.py` examples use **FSSA** +(`add_natural_bc(δt·(Γ·v)Γ/2, "Upper")`). **That is NOT our method.** FSSA buys +stability by adding an implicit surface traction that **UNDER-deforms the surface** +— it trades accuracy for stability. Our scheme is designed to be stable **and** +accurate. If you find yourself adding `FSSA`, an `add_natural_bc` traction on the +free surface, or a `Gamma.dot(v)` stabiliser — STOP, you have the wrong method. +(Those example files are a template for a *different* approach, not this one.) + +## The three-number pointwise integrator (THE method) + +Two Stokes solves per step on the SAME mesh, then a pointwise surface update: + +1. **Free solve** — stress-free top (NO velocity BC on `Upper`; pressure datum is + pinned by the stress-free condition → no pressure nullspace). The surface + normal velocity `u_n` of this solve IS the kinematic rate `ḣ`. +2. **Held-lid solve** — a second Stokes solve with a RIGID free-slip held lid + (`u_n = 0`, via `add_nitsche_bc(0.0, "Upper", local_h=True)` — see [[project_nitsche_local_h_pr275]]) + and a DRIVING-ONLY body force. Its surface normal stress `σ_nn` gives the + equilibrium topography `h_∞ = -(σ_nn - mean)/ρg`. (The free solve forces + `σ_nn = 0`, so the equilibrium MUST come from the held-lid stress.) +3. **Pointwise exp step**, per surface node, from THREE numbers (`h`, `ḣ=u_n`, + `h_∞`): + ``` + γ = ḣ / (h_∞ − h) # local relaxation rate, clamp γ ≥ 0 + h ← h_∞ + (h − h_∞)·exp(−γ·dt) + ``` + L-stable: the step is bounded between `h` and `h_∞`, so it **cannot overshoot** + regardless of a noisy local `γ` (no "drunken sailor"). 1 extra solve/step; + beats RK4 at large dt. NO per-node freeze-clamp (that was the old `relax` bug). +4. The nodal surface increment is **carried inward by a Laplacian diffuser** + (smooth, minimal mesh deformation — NOT full mmpde adaptation), then + `mesh.deform()`. Uniform meshes are fine; adaptivity is NOT required. + +Reference impl: `fs4_compare.py` → `_surface_step`, `_h_inf` (held-lid σ_nn via a +`Projection`), `_surf_un`, `_carry_diffuser`. The free-slip RIGID-top run (no +surface motion) is the reference; the free-surface run is the same driving solve +PLUS this surface update. See [[project_fs4_adaptive_2x2]], +[[project_stress_equilibrium_freesurface]], [[project_freeslip_topo_freesurface]]. + +## Performance + +- Free-slip (rigid top) stagnant-lid runs are FAST. The free-surface cost is the + extra held-lid solve **plus** that moving the surface forces a COLD-START Stokes + each step (can't warm-start across a deformed mesh). +- Use **uniform** meshes for this problem — the diffuser gives minimal deformation, + no mmpde needed. (FMG works on a uniform `refinement=N` hierarchy; scalar solvers + must avoid FMG — PETSc err62, issue underworldcode/underworld3#276.) + +## Hardening strategies (2026-06) — the integrator alone is not enough + +The 3-number integrator moves the surface correctly, but several *other* things must +be right or it runs away / tangles. All implemented in `fs4_compare.py` (flags noted). + +### 1. Material-surface advection — THE key fix (`--advect-velocity`) +The runaway (`u_n` 42→125→285→445, cold lid leaking in, plumes punching through) was +NOT an `h_∞`/BC bug (`h_∞` is verified correct, even in the stagnant FK lid — held-lid +free-slip is the EASY case there). The bug: the surface moves by the L-stable relaxed +rate `ũ_n = Δh/Δt ≤ u_n`, but T was advected with the **stress-free solve velocity** +(surface-normal = full `u_n`). Net material then crosses the surface. A free surface is +a MATERIAL boundary: advect T with a velocity whose surface-normal = `ũ_n`. Modes: +- `consistent` (the right way): a THIRD Stokes solve, same buoyancy, `v·n̂ = ũ_n` + PRESCRIBED at the surface (penalty), tangential stress-free. `ũ_n = (shape_new−shape0)/dt` + = the full ∂h/∂t at fixed θ (correct ALE target). +- `blend`: `α·v_free + (1−α)·v_held`, `α = φ1(γΔt) = (1−e^{−γΔt})/(γΔt)` (the exp-decay + time-average). By Stokes LINEARITY this *is* the prescribed-`ũ_n` solve for UNIFORM α + (and free for FK, which is linear in v). BUT the single mean-α collapse is NOT close + enough once γ varies per surface node — the planform diverges (mode-3 vs mode-1/2), + throughflow ~23 vs ~0.08. Per-node α breaks div-free (∇α·(v_free−v_held)). So the + per-node `consistent` 3rd solve is REQUIRED for structured planforms. +- `free`: advect with stress-free v (the inconsistent baseline — the runaway). + +### 2. Tangential topography advection (`--no-tangent-advect` to disable; default ON) +The pointwise relaxation omits the `v_t·∂_s h` term — a surface rotation/convergence +should carry the topography pattern along the surface; without it you get edge artefacts +where ∂_s h is large (plume-bulge edges). Fix = operator split per step: (1) departure- +point semi-Lagrangian transport of the surface shape in θ by `ω = v_t/r`, then (2) the +L-stable normal relaxation. Lowers throughflow + improves mesh quality. + +### 3. Free-slip inner boundary — rotation nullspace (`--inner freeslip`) +The rigid rotation `[-y,x]` is a velocity nullspace ONLY while the boundary is CIRCULAR. +Once the free surface DEFORMS, do NOT attach it to the held/consistent solves (→ held +22 s/`DIVERGED_LINEAR_SOLVE`, throughflow blow-up). Keep `petsc_use_pressure_nullspace`; +strip the gauge with the exact post-solve projection `_project_out_rotation` on `v`, +`v_cons` (drives advection) AND `v_h` (one consistent non-rotating frame). The undeformed +free-slip *reference* (`--surface freeslip`) is fine WITH the nullspace attached. + +### 4. Graded / higher-order meshes (drive node movement consistently) +- **Surface-ring detection**: tie the tolerance to the FINEST cell + (`0.5·mesh.get_min_radius()`), NOT the nominal `cellsize`. On a gmsh-graded mesh + (`cellSizeOuter`) the old tolerance scoops the first interior ring → a 2%-thick + "surface band" → tangling (looks like the surface "destroying itself"; it isn't — + the diffuser was fed a corrupt surface). BETTER (TODO): build the ring from the DMPlex + `Upper` label (`dm.getLabel("Upper").getStratumIS`), removing the tolerance entirely. +- **Node movement**: the solve velocity is P2; the mesh geometry is P1. Drive `u_n` and + the tangential transport from a P1 length-smoothed `Vector_Projection` of V (`v_p1`), + NOT a point-evaluation of the P2 field. +- **Stress smoothing**: `topo_proj.smoothing_length` = a fixed PHYSICAL length + (`--smooth-length`), not cell-count, so `h_∞` is mesh/order-independent. + +### 5. Cost — there is no acceleration win (don't chase it) +3 Stokes solves/step (free→u_n, held→h_∞, consistent→advect). Warm-start does NOT help +(outer KSP already 1 iter; FMG supplies its own nested guess — measured SLOWER). Blend- +skip rarely fires (α-spread always large). Operator/PC reuse: already reused across +`solve()`s (first 5.5 s setup, steady ~945 ms = irreducible FMG solve; RHS-only resolve +same cost). The per-step cost is the geometric FMG hierarchy REBUILD on the deforming +mesh — intrinsic to moving meshes, "live with it." The UNIFIED-PENALTY single solver +(`penalty·(v·n̂ − V₁·n̂)·n̂`; penalty=0→free, V₁=0→held, V₁=ũ_n→consistent — held & +consistent share the matrix) is the cleanest formulation (no recompile on the constant) +but doesn't cut the irreducible solve. + +### Elastic-plate flexure `h_∞` — IMPLEMENTED (`--flexure-D`) +Generalizes the LOCAL Airy `h_∞ = −σ_nn/ρg` (the D=0 limit) to a flexed plate +`(D ∂_s^4 + ρg) h_∞ = −σ_nn`, solved SPECTRALLY on the ring (serial Fourier — the +feasible substitute for UW3's blocked 1D-manifold FE solve): per mode +`h_∞(m) = −σ_nn(m)/(ρg + D(m/r_o)⁴)`. `D` sets the flexural wavelength `(D/ρg)^{1/4}` +and damps short-wavelength loads — the physically-grounded, mesh-independent length- +smoothing. In `_h_inf` (h_∞-ONLY — the stable form). **PROTOTYPE — amplitude response correct +(stiffer plate → less deflection) but it does NOT low-pass the SURFACE**: filtering `h_∞` only +sets a smooth set-point; the surface still picks up short-wavelength content from the SL +tangential transport + partial relaxation. "Filter every surface number (h, ḣ, h_∞)" was +TRIED and REJECTED — filtering the GEOMETRY `h` injects a spurious smooth-the-mesh motion into +`ũ_n` → flow runs away (Vrms 50→345); filtering `ḣ` alone is stable but elevates Vrms with no +benefit. So making flexure a TRUE surface low-pass without destabilising is OPEN/hard +(`_flex_filter` helper is in place). Examples: `stagnant_lid_mode1_study/{figures/flexure_*.png, +runs/flexure_D*}`. + +### Open / next (not yet done) +- **Label-based surface ring** (replace the radial heuristic with the `Upper` stratum). +- **Flexure D calibration** to a realistic lithospheric flexural wavelength. + +### Diagnostic tools (`~/+Simulations/FreeSurface/convection/stagnant_lid_mode1_study/scripts/`) +- `heldlid_hinf_check.py` — verify `h_∞` via 4 independent free-slip enforcements × Δη sweep +- `stitch_compare.py` — side-by-side montage of per-run dirs (`--dirs a,b,c`) +- `unified_penalty_solver.py` — the one-solver penalty formulation probe +- `resolve_timing_probe.py` — repeated-solve / lag-Jacobian / reuse-PC timing + +## ★★ Body force must be FULL Boussinesq on a deforming mesh (2026-07-26) + +**On any run where the mesh surface actually moves, the body force must retain the +ρ₀ background: `bodyforce = (thermal_buoyancy − rho_0_g)·r̂` (i.e. ρ = ρ₀(1−αΔT)).** +The reduced (driving-only) form has **NO restoring force for surface deformation +anywhere in the momentum system** — `buoyancy_scale` enters only the kinematic target +h∞ = −σ_nn/ρg, which exerts zero force on the flow. Consequences and evidence (all +measured, `~/+Simulations/FreeSurface/annulus_fs_convection/teaching/`): + +- **Relaxation A/B (`relaxation_test.py`)** — imposed 5% mode-4 topography, no thermal + driving: reduced form = surface FROZEN (velocities are round-off); full density = + **262→5 km in 10 steps at the Cathles rate** (measured 2.1e4 vs ρ₀g/(2ηk) = 2.5e4, + within 20% at res 0.06). +- **Convection A/B (`restoring_force_demo.png`, gap-Ra 3e4, ρ₀g 1e6)** — reduced h + grows monotonically without limit; full density *rings* about its supported amplitude + early (damped), then tracks the developing flow ~35% lower with HIGHER Nu (1.93 vs + 1.65). Without the term, soft surfaces run away entirely (measured to 50% of radius). +- **The FreeSurface manager needs NO change** — held/consistent solves inherit + `stokes.bodyforce`; h∞ then self-consistently includes the self-load (a moving target + the integrator follows cleanly — verified in the relaxation test). + +Rules that come with it: +1. **ρ₀g and Ra are COUPLED: αΔT = (thermal buoyancy coeff)/ρ₀g must be ≲ 0.3.** + Softness is not a free knob — the old "soft" runs at ρg=2e5 were αΔT = 1.2–4 (no + such fluid) and their runaway was partly parameter nonsense. Soft surfaces require + weak driving. +2. **Tighten the solver tolerance (~1e-8)**: the hydrostatic RHS dominates the dynamic + signal by 1e3–1e4, and a relative tolerance judges the total. +3. Prefer `snes_type=ksponly` for the isoviscous solves; the huge RHS makes `newtonls` + thrash worse. +4. Driver switch: `fs_convection.py -uw_full 1`. +5. **`FreeSurface(background_buoyancy="analytic")` is REQUIRED with full density** + (98961147): the recovered reaction contains the self-load +h_current and the + reduced-form negation otherwise flips it -> h_inf = -h + drive/rho_g (parks at HALF + equilibrium; -1 eigenvalue = period-2 ringing; steady flow THROUGH the stationary + surface). "analytic" subtracts the geometric height - no extra solve. The CBF + recovery itself is exact (probe lesson: select boundary DOFs by LABEL, never a + radius mask, on a deformed mesh). `background_buoyancy=` = exact two-reaction + reference mode. + +Related transport fact (same campaign): the serial T-blow-up on deforming FS meshes was +the **old-frame SL reach-back** amplifying per loop cycle (issue #423; smaller dt makes +it WORSE) — retired in `b507aca1`; the manager now uses the standard ALE path + clamp + +deform-aware foot restore. The parallel datum defect is #421. + +## Failure modes — symptom → cause + +| Symptom | Cause | +|---|---| +| Surface deforms but `u_n` RUNS AWAY (e.g. u_n 42→125→285→445), cold lid leaks in, plumes punch through | **RESOLVED**: material-surface advection inconsistency — T advected with stress-free `u_n` while surface moves by relaxed `ũ_n`. Fix = `--advect-velocity consistent` (Hardening §1). NOT an h_∞ bug, NOT fixed by FSSA. | +| `held` solve 22 s / `DIVERGED_LINEAR_SOLVE`, throughflow blows up, with `--inner freeslip` | rigid-rotation `[-y,x]` attached as a nullspace on the DEFORMED (non-circular) surface — invalid. Don't attach it on the moving surface; use the post-solve projection (Hardening §3). | +| Graded-mesh surface "destroys itself" (q→0.2, h_max 2% at step 1) | surface-detection tolerance scooped the first interior ring → 2%-thick band, NOT real deformation. Tie tolerance to finest cell (Hardening §4). The diffuser is innocent. | +| Topography GROWS without saturating; flow-through persists even at huge deformation | **missing ρ₀ background** — reduced body force has no surface restoring force (see the Full-Boussinesq section above). Fix = ρ = ρ₀(1−αΔT); check αΔT ≲ 0.3 | +| T leaves [0,1] on a deforming mesh, mesh-locked hot/cold spikes in the squeezed band, worse at SMALLER dt | old-frame SL reach-back amplifying per loop cycle (issue #423) — use the standard ALE path (`old_frame_traceback=False`, the manager default since b507aca1) | +| Stress-free top but surface not updated each step | nothing stops throughflow (the stress-free top is an open boundary unless the integrator moves the surface to track `u_n`) | +| Nu decays when it should be steady (kinematic free surface) | LAG: the SL foot reaches beyond an under-moved surface → cold pump. Fix = the h_∞ relaxation, not more smoothing | +| Surface "mountain" / one-step spike on adaptive mesh | held-lid Nitsche penalty over-stiffened by GLOBAL h; use `local_h=True` (default, PR #275) = `mesh.cell_size()` | + +## Dead ends (already tried — do NOT repeat) + +- **FSSA** signed-traction free-surface: diverges / under-deforms — rejected. +- **High-k post-smoothing** of the surface: the instability is low-m, smoothing + the wrong band. +- Per-node freeze-clamp in the relaxation (the old `relax` fatal bug). + +## Diagnose by + +`h_max` (deflection, as % of r_o), `u_n` / `vhmax` (surface throughflow — should NOT +grow unbounded), `hinf_max` (the equilibrium target), `vrms`, `Nu`. Compare the +free-surface run against the free-slip RIGID-top reference at matched physical time. diff --git a/docs/developer/guides/nonlinear-solver.md b/docs/developer/guides/nonlinear-solver.md new file mode 100644 index 000000000..1674d0bf7 --- /dev/null +++ b/docs/developer/guides/nonlinear-solver.md @@ -0,0 +1,323 @@ +--- +name: nonlinear-solver +description: How to make a hard nonlinear Stokes solve (Drucker-Prager / yield-stress viscoplastic) CONVERGE reliably in Underworld3 the way the working recipe actually does it — automatic warm-start (one Picard step on a cold start) plus a MULTI-SOLVE δ-continuation (constant δ per solve, warm-start the next, sharper δ), the consistent-Newton tangent, and a non-symmetry-safe multigrid smoother. Reach for THIS when a viscoplastic solve stalls / diverges and you are about to hand-tune PETSc options, ramp δ, or "just add a monitor". It carries the CONFIG TRAP LIST — the setup mistakes that each produce a different failure a few steps in — and the one thing you must NOT do (ramp δ inside a single SNES solve). For the yield-law maths and which tangent per model, see `plasticity-solvers`. +families: [Stokes, ViscoPlasticFlowModel] +kind: recipe +--- + +# nonlinear-solver + +The recipe that gets a **hard viscoplastic (Drucker–Prager) Stokes** problem to +converge, and — more importantly — the list of setup mistakes that stop it. The +central lesson from the Spiegelman hard-case study (`η_bg=1e26`, `V=10`): every +failure was a **solver-configuration** error, not a bad Jacobian. If the correct +setup is a minefield for an expert, that is an API regression — so the goal is to +make the correct path the default path. + +Design of record: `docs/developer/design/nonlinear-solver-homotopy-warmstart.md`. +Yield-law maths, tangent-per-model, quadratic-convergence check: `plasticity-solvers`. + +--- + +## The recipe (what actually converges) + +1. **Warm start.** Start the continuation at **large δ**, where the yield surface is + smooth and the problem is easy, and take **one Picard step** into the Newton + basin. One Picard step is defect-correction iteration 1 — contractive, cheap. From + a *warm* iterate, take **no** Picard step (it wastes the good quadratic start). + A cold `v=0` start is safe on its own terms: `ε̇=0` makes `η_pl` infinite, which + the soft-min carries to the viscous branch (see the trap list for the one form + that must be written carefully). + +2. **If a single solve at the sharp surface fails**, escalate in this order: + **grid sequencing first** (solve coarse, transfer, re-solve fine — the + measured 2-3x win at the notch), and only then a **multi-solve + δ-continuation** as the rescue of last resort. The δ-discipline, when you do + reach for it: hold δ **constant** for a full nonlinear solve to tolerance; + warm-start the next, smaller δ from that converged state; march down to the + sharp surface. δ is a `constants[]` atom, so each step is a recompile-free + `PetscDSSetConstants` update. + + The packaged driver is `stokes.solve(homotopy=True)` (also callable directly + as `underworld3.systems.yield_continuation`; tune with + `homotopy_options=dict(delta0=…, down=…, dmin=…, entry_maxit=…, + step_maxit=…)`). **Treat it as a rescue, not the default**: the evidence + that once made a δ-march the recommended entry point was retracted (it + rested on a unit-scaling error — `plasticity-solvers` carries the ruling + and the surviving evidence), and the driver's documented cold-start + guarantee does not currently hold (issue #473: entry can fail on a + pressure-dependent yield, and the step control is effectively one-shot). + Newton + the automatic Picard entry handles the standard cases without it. + +3. **Consistent-Newton tangent** for non-elastic DP (`consistent_jacobian=True`); + **Picard** for elastic VEP — see `plasticity-solvers` for the per-model table. + +4. **`bt` line search** with the consistent tangent on a smooth (δ>0) surface. + +--- + +## DO NOT ramp δ inside one SNES solve + +Ramping δ **inside a single SNES solve** (a `SNESSetUpdate` callback that sharpens +the yield surface between Newton iterations) is **proven dead** — it diverges +`DIVERGED_LINEAR_SOLVE` after ~2 iterations and grinds for ~2 hours, **even on the +proven solver config**. Mechanism: the continuation only sharpens δ from a +**converged**, well-conditioned iterate; the in-SNES ramp sharpens δ **mid-solve** +at a far-from-solution iterate where the consistent-Newton Jacobian on a sharpening +surface is ill-conditioned and the linear solve fails. **Hide the *continuation*, +not the *ramp*.** (An in-SNES ramp API once shipped and has been removed from the +source entirely — use the multi-solve continuation above; `plasticity-solvers` +carries the yield-law substrate and the evidence on when a δ-march is worth it +at all.) + +--- + +## CONFIG TRAP LIST + +Each of these produces a *different* failure a few steps in — that is why the hard +case felt like whack-a-mole. Check them first. + +| Trap | Symptom | Fix | +|---|---|---| +| **Perfect plasticity's consistent tangent is SINGULAR along the flow**: on the hard-`Min` plastic branch η = τ_y/2ε̇_II, so 2η + 2η′ε̇_II = 0 — the velocity block is symmetric but semi-definite in every yielded cell. (An earlier version of this row blamed *asymmetry*; that is wrong for any η(ε̇_II) law — the rank-one term η′ ε̇⊗ε̇/ε̇_II is symmetric. Pressure-dependent yield adds a non-symmetric v–p coupling, not a non-symmetric velocity block. Corrected 2026-08-26, maintainer review.) | benign while yielded cells are few (the viscous neighbours regularise); with a large yielded fraction the velocity sub-solve caps out and Newton stalls at ~1e-3, no failure reason | give the plastic branch a positive tangent: a small δ soft-min (`yield_mode="softmin"`, powermean, `yield_anchor="yield"`), a rounded viscosity floor, or rate-strengthening ξ; Picard converges regardless (full 2η stiffness) but is linear-rate. The FMG bundle's `gmres`+`sor` smoother is Newton-safe either way | +| `preconditioner="fmg"` (vs explicit `pc_type=mg` + manual mg opts) | outer KSP "converges" in **1 iteration** → no real Newton correction → stall → `DIVERGED_LINE_SEARCH` | use explicit `pc_type=mg` with the smoother opts above; bound the outer KSP (`ksp_max_it`~80) so a hostile step fails fast | +| Cold plastic start `v=0`, or any rigid/unyielded point | `DIVERGED_FNORM_NAN` at iteration 0 | **Not** a div/0: `ε̇=0` gives `η_pl=+inf`, which `Min` and the sqrt soft-min carry correctly to the viscous branch. Only a soft-min form that computes `η_ve·η_pl/(η_ve+η_pl)` breaks (`inf/inf`). Fixed in the power-mean; if you hand-roll a blend, write the harmonic mean as `η_ve/(1+η_ve/η_pl)`. **Do not reach for a strain-rate floor** — it hides this rather than fixing it | +| LU velocity block with all-Dirichlet-ish BC | pressure nullspace singular | attach the Stokes nullspace / avoid a bare LU there | +| Hand-rolled `snes_monitor` to "see what's happening" | you read residuals but miss the tell | use `solve_with_diagnostics` / `get_snes_diagnostics` instead (below) | + +**The diagnostic tell:** `solver.get_snes_diagnostics()["linear_iterations"] ≈ 1` +per Newton step means the linear solve is doing **no real work** (the FMG-1-iteration +trap). A healthy consistent-Newton solve does real Krylov work each step and +converges quadratically. Use `solve_with_diagnostics()`, not a hand-rolled monitor. + +--- + +## Automatic warm-start (Layer 1 — landed) + +`solver.has_solution` is a **public, read-only** status flag: `True` only after a +solve whose SNES converged; reset on a structural rebuild (remesh / adapt / +mesh-mover — the `is_setup=False` hook); kept through coefficient changes (viscosity, +δ, BC values, time step). A **diverged** solve leaves it `False`, so the next solve +auto-cold-starts rather than warming off a corrupted iterate. + +On a **cold** (`zero_init_guess=True`) Stokes solve under the **consistent-Newton +tangent**, a single Picard step is now taken automatically (reusing the existing +`picard=1` machinery). The default (frozen) tangent path is bit-identical. + +```python +stokes.consistent_jacobian = True +stokes.solve() # cold → one automatic Picard step, then Newton +if stokes.has_solution: + ... +``` + +--- + +## Implementation status (this line of work) + +- **Layer 1a — DONE:** `has_solution` + cold consistent-Newton Picard warm-up + (`petsc_generic_snes_solvers.pyx`; test `test_0201`). +- **Layer 1b — DONE:** `zero_init_guess` is tri-state — `None` (default) auto-detects + from `has_solution`, `True` forces fresh, `False` insists on warm. Note warm and cold + agree only to the *convergence tolerance*, not bitwise. +- **Layer 3 — DONE:** the FMG velocity smoother defaults to `gmres`+`sor` with + `mg_levels_ksp_norm_type=none` (fixed-cost V-cycle), unconditionally — see + "Multigrid depth" below. +- **Layer 2 — SHIPPED, DEMOTED TO RESCUE:** the model advertises the homotopy + (`supports_yield_homotopy` / `_yield_homotopy_control`) and + `stokes.solve(homotopy=True, homotopy_options=...)` runs the residual-guided + continuation, returning the march summary. The doctrine that made this the + recommended entry point was retracted (unit-scaling error — see + `plasticity-solvers`), and its cold-start guarantee is broken (issue #473); + use it after Newton + Picard entry and grid sequencing have failed. + +--- + +## Multigrid depth — how to measure a smoother honestly + +**A two-level hierarchy is a coarse-grid correction, not a V-cycle.** Smoother +comparisons made on one are misleading: the gmres-over-richardson margin measured on +the Spiegelman notch is only 5 % at 3 levels but **25 % at 4** (ρ per V-cycle 0.746 → +0.560), because a deeper cycle applies the smoother on more coarse operators. Judge a +smoother at depth or not at all. + +To get depth without a monster problem, refine a **deliberately ultra-coarse NESTED +base**: `make_notch_mesh.py 1` (492 cells) + uniform `refinement=N` gives 3 levels / +7,872 cells at `N=2` and 4 levels / 31,488 at `N=3` — deeper *and* smaller than the old +2-level 38,580-cell setup. In MG you want the coarsest grid as coarse as it can be +before the problem breaks down. + +- **Never use a non-nested hierarchy** here — it does not give strong MG convergence + (maintainer ruling). Uniform refinement nests by construction. +- Accepted tradeoff: uniform refinement does **not** snap new boundary nodes back to + the analytic notch arcs (no CAD/EGADS model attached), so the corner geometry is + frozen at the coarse mesh's chords on every level. + +Measure with `fmg_contraction_probe.py` (ρ_MG per V-cycle; `<0.5` healthy, `0.8–0.95` +struggling, `≥0.98` hangs) or `smoother_depth_sweep.py` (pays the mesh build + viscous +seed once, sweeps smoothers in-process) in the Spiegelman study. + +**`solve_report` cannot see the smoother.** It records the *Newton* contraction; the +outer KSP is Eisenstat–Walker-collapsed to ~1 iteration/step, so the smoother's work +hides inside the velocity sub-block. Probe the `fieldsplit_velocity_` sub-KSP directly. + +**A smoother will not rescue small ξ.** At the hard corner the failure is operator +conditioning — the coarsest grid cannot represent the viscosity contrast — and at 4 +levels *every* smoother fails there (richardson outright, gmres with ρ>1). Use the δ/ξ +continuation to stay in the solvable region. + +## FMG on an ADAPT-ON-TOP child (locally refined meshes) + +An `adapt()` child carries its **own custom-P geometric MG tail** — subsampled to +one level per **DOUBLING of h** (`mg_coarsening_ratio=2.0`, the `adapt()` default) +— on `child._custom_mg_coarse_meshes`, and solvers built on it pick it up +automatically. So the usual advice above ("never use a non-nested hierarchy") is +satisfied without you assembling anything: + +```python +child = base.adapt(metric, max_levels=3, engine="edge_split") +stokes = uw.systems.Stokes(child, velocityField=v, pressureField=p) +stokes.solve() # pc=mg auto-attached off the child's tail +``` + +Requirements and traps, all measured: + +- **Build the base with `refinement>=1` for a deeper tail.** The custom-P tail + always starts at the BASE mesh — with `refinement=0` it is + `[base] + the intermediate doubling levels`, so there IS a coarse grid — but + the uniform base levels extend it downward, and in MG you want the coarsest + grid as coarse as it can be. +- **Keep the GRADED tail.** `_adapt_nested` stores one MG level per doubling of + resolution (`_subsample_mg_levels`; per-bisection-pass levels were measured + 2.3–7.3× slower). Handing the solver a base-only tail instead — coarse base + straight to the fully adapted mesh — **triples the V-cycle count**. +- **V-cycle counts are insensitive to element quality here, and that is a PASS not + a failed measurement.** On a fault child the velocity block takes 2 iterations + (iso) or 2–3 (TI) across meshes ranging from 156° to 105° max angle. The + geometric hierarchy's coarse spaces come from the mesh hierarchy, not from the + fine operator, so shape does not move it — which is exactly what makes + adapt-on-top viable. **If you want a solver-side probe of mesh quality, use + GAMG**, which does respond (iso 79 → 64 velocity iterations with `repair=True`). + That is now actionable: `solver.preconditioner = "gamg"` is **respected** on an + adapt child (#530) — before that guard the opportunistic pickup silently + clobbered it back to `pc=mg`, so any FMG-vs-GAMG comparison was vacuous. +- **Single-field solvers get FMG too** (#478/#534): `preconditioner = "fmg"` on a + Poisson/projection-class solver builds the custom-P tail over the mesh's own + `dm_hierarchy` — the section is not Stokes-or-adapt-child only. +- **`relax()` can trip #424.** On a relaxed, unrepaired child the barycentric + transfer hit 22 zero columns and fell back to the DENSE global RBF builder — a + performance cliff, not just a warning. +- **Every PC degradation is recorded in `solver.pc_fallbacks`** (#534) — the + requested/installed/reason record for the #424 barycentric→rbf retry, a + collapsed hierarchy, a declined pickup. Read that, don't scrape warnings. +- **`repair=True` invalidates the any-degree nested transfer** (a flipped cell can + straddle two coarse cells), so degree ≥ 2 falls back to the geometric builder. + The exact ½,½ vertex prolongation survives, because flips move no vertex. +- Under **rotated free-slip** the mesh-owned adapt tail is picked up automatically + too — the rotated KSP resolves hierarchies through the same + `custom_mg.build_transfers` rule (#467 fixed the old silent GAMG fallback). See + the `adapt-on-top-faults` skill for the plain-refined-mesh case, which still + needs `set_custom_fmg`. + +Companion skills: **`adapt-on-top-faults`** (building the child, engines, repair, +band sizing), **`adaptive-meshing`** (the mover, and `relax(pin_bands=...)` for +relaxing a mesh that was refined onto an interface). + +## The Schur complement: pair the penalty with FMG, never with GAMG + +**Symptom this is for**: the velocity block's iteration count is rock solid but +the pressure sub-solve wanders into the hundreds and eventually stops +converging. + +**First: it is probably not the pressure block.** `S = -B A^-1 B^T` is applied +*through* the velocity solve, so a velocity solve that exits at its iteration +cap makes the Schur operator inconsistent between applications — and no Krylov +method converges against an operator that moves under it. The pressure block +then caps too, and the outer flounders. Measured on SolCx (eta 1e6, P2-P0disc, +h=1/30), changing **only** `fieldsplit_velocity_ksp_max_it`: + +| velocity cap | sec | outer | pressure/app | velocity/app | +|---|---|---|---|---| +| 200 (default) | 976.0 | 44 | **200.0** | **200.0** | +| 5000 | **25.6** | **2** | **30.0** | 618.0 | + +**38x from a number that is not in the pressure block**, and the velocity error +is identical in both rows. Before tuning the Schur solve, check whether either +block sat at exactly its cap — `solve_report.sub` gives iterations and +applications per block, and a per-application count equal to the cap to the +digit is the tell. + +**Then: the penalty is the lever on the Schur count, and it needs FMG.** +`stokes.penalty = lambda` adds `lambda*mu*(div u)(div v)`, which makes the +eta-scaled mass matrix a better approximation to S. Matched on one mesh +(2592 cells), same discrete solve, only the velocity preconditioner differs: + +| lambda | velocity PC | sec | outer | Schur/app | velocity/app | velocity total | +|---|---|---|---|---|---|---| +| 0 | GAMG | 15.49 | 2 | 125.5 | 94.7 | 24802 | +| 0 | **FMG** | **3.88** | 1 | **59.0** | **8.8** | **546** | +| 10 | GAMG | 20.68 | 7 | 22.3 | **199.9 capped** | 33976 | +| 10 | **FMG** | **3.06** | 1 | **18.0** | **13.5** | **270** | + +- **With FMG, `penalty = 10` improves every axis at once**: 21% faster, Schur + count 3.3x smaller, total velocity work halved. FMG absorbs grad-div + augmentation (8.8 -> 13.5 iterations per application); GAMG does not + (94.7 -> capped). +- **With GAMG, do not use it at all.** The same `penalty = 10` makes the solve + *slower* (15.49 -> 20.68 s), because augmentation is exactly what drives GAMG + into its cap. Uncapping rescues it to 11.03 s but it still needs **833** + iterations per application, and FMG is 3.6x faster on the same mesh. + Feasible is not competitive. + +**The accuracy cost is consistent, so it is safe to pair by default.** The +penalty is grad-div, not a true augmented Lagrangian — `div(P2)` is not inside +`P0`, so the term does not vanish at the discrete solution and it does perturb +the answer. But the perturbation converges away: same rate, and the gap shrinks +under refinement. + +| cells | lambda=0 v err | rate | lambda=10 v err | rate | gap | +|---|---|---|---|---|---| +| 648 | 2.112e-1 | — | 2.327e-1 | — | 1.102 | +| 2592 | 1.266e-1 | 1.67 | 1.376e-1 | 1.69 | 1.087 | +| 10368 | 8.727e-2 | 1.45 | 9.305e-2 | 1.48 | **1.066** | + +For a pressure-dependent constitutive law use the mechanical pressure, +`p_mech = p - lambda*mu*(div u)`; the raw `p` is the multiplier. + +**Traps.** + +- **FMG needs a refined base or you silently get GAMG.** Measured: + `refinement=0` -> one hierarchy level -> default velocity PC is `gamg`; + `refinement=2` -> `mg`. So `penalty` set "with FMG" on an unrefined mesh is + actually the harmful GAMG pairing. Check + `snes.getKSP().getPC().getFieldSplitSubKSP()[0].getPC().getType()`, or read + `solver.pc_fallbacks`. +- **Scaling `saddle_preconditioner` by a constant does nothing** — it does not + change the Krylov subspace. `1/eta` and `101/eta` both give 28 iterations, + identical to every digit, so an "AL-matched" `1/(eta*(1+lambda))` cannot help. + The 1/eta *weighting* itself is worth 1.9x (28 vs 52 with a flat `1`). +- **Eisenstat-Walker is inert under `snes_type=ksponly`** — identical iterations + and error on or off. And `outer 1` is not an EW artefact: it is what a full + Schur factorisation gives when the Schur complement is solved well. +- Measurements: `~/+Simulations/pressure_schur_625/` (#625). + +## Gotchas + +- **`./uw build` → `amr-dev` env**; verify `uw.__file__` is the worktree site-packages. +- **Run VEP/consistent-Newton tests UNFORKED** — `pytest --forked` SIGABRTs (fork of + multithreaded PETSc). +- Benchmark **every** default change — "Solver Stability is Paramount". +- ξ (rate-strengthening) is a **non-homotopic** regularisation: put a user loop + *around* `solve()`, never inside the δ-march. + +## Reference + +- Design: `docs/developer/design/nonlinear-solver-homotopy-warmstart.md`, + `jacobian-consistent-tangent.md`, `solver-strategies-catalogue.md`. +- Continuation driver: `underworld3.systems.yield_continuation`. +- Diagnostics: `SNES_*.get_snes_diagnostics()` / `solve_with_diagnostics()`. +- Related skills: `plasticity-solvers` (yield law + tangent per model), + `free-surface-convection`, `adaptive-meshing` (mover + `relax(pin_bands=...)`), + `adapt-on-top-faults` (locally refined children and their MG tail). +- Reconnection / refinement engines: + `docs/developer/design/mesh-reconnection-and-delaunay-adapt.md`. diff --git a/docs/developer/guides/plasticity-solvers.md b/docs/developer/guides/plasticity-solvers.md new file mode 100644 index 000000000..6d0e3c56c --- /dev/null +++ b/docs/developer/guides/plasticity-solvers.md @@ -0,0 +1,228 @@ +--- +name: plasticity-solvers +description: How to get hard-Min viscoplastic / visco-elastic-plastic (VEP) Stokes solves to CONVERGE in Underworld3 — Newton with the automatic Picard entry (solver.consistent_jacobian), which tangent per model, grid sequencing for the hard cases, and the δ-soft-min substrate (yield_mode / yield_smoother / yield_anchor) as a modelling choice. Reach for THIS first when a Drucker-Prager / yield-stress Stokes solve stalls, diverges (DIVERGED_LINEAR_SOLVE / line-search fail), or grinds through ~20+ nonlinear iterations. Tells you which tangent to use per model, how to confirm you are actually running Newton, and the measured failure modes. For the solver-config trap list and multigrid, see `nonlinear-solver`. +families: [Stokes, ViscoPlasticFlowModel, ViscoElasticPlasticFlowModel] +kind: recipe +--- + +# plasticity-solvers + +The workable recipe for **nonlinear convergence of yielding (viscoplastic / VEP) +Stokes** in Underworld3. Hard-`Min` yield laws have a non-differentiable kink that +breaks naive solvers; this encodes what the yield campaigns measured actually works — +and records what was retired. + +**The default call is now just:** + +```python +stokes.constitutive_model = cm # ViscoPlastic / ViscoElasticPlastic / TI-VEP +cm.Parameters.yield_stress = tau_y # finite -> plasticity active +stokes.consistent_jacobian = True # Newton tangent (non-elastic DP; see table) +stokes.solve() # cold start takes ONE Picard step automatically +``` + +--- + +## The doctrine (measured, 2026-07 campaigns) + +Yielding viscoplasticity is `η_eff = Min(η_visc, η_yield)`, +`η_yield = τ_y/(2·ε̇_II)`. The `Min` kink is what makes it hard. + +1. **Picard is an ENTRY requirement, not an accelerator.** On a cold start under + the consistent tangent, `solve()` takes one automatic Picard (frozen-tangent) + step and then runs Newton (fires only when `picard==0`, + `consistent_jacobian is True`, and the start is cold — from a warm iterate, + 0 Picard really is 0). Do NOT front-load Picard where Newton works: measured, + opening with 5 / 25 Picard steps cost 12 / 30 total iterations against pure + Newton's 7. + +2. **Newton-first; spend Picard only to rescue.** When Newton fails + (`DIVERGED_LINE_SEARCH` / `DIVERGED_LINEAR_SOLVE`) **or stalls admissibly** + (steps accepted, residual flat — no FAIL reason ever fires), revert to the best + iterate and buy a Picard block: `solve(picard=N)`, or + `consistent_jacobian="continuation"` (staged Picard→Newton α-blend; α is a + `constants[]` atom, no recompile). Rescue on *stagnation*, not only on a + failure reason — the failure-only trigger measured byte-identical to doing + nothing at the cliff. + +3. **Grid sequencing is the validated warm start for hard problems.** Solve + coarse (it finds the localisation structure cheaply), transfer the state up + (the linear-exact local RBF, #430), warm-start the fine solve. Measured on the + notch: 2–3× deeper residual, more localised, fewer iterations than any cold + fine strategy. No packaged API yet — hand-roll the cascade with + `uw.function.evaluate` per level; PETSc's `-snes_grid_sequence` does NOT work + on UW3 meshes. See `docs/developer/design/multilevel-nonlinear-stokes-strategy.md`. + +4. **`solver.has_solution` / tri-state `zero_init_guess`** make warm-started + campaigns safe: `None` (default) auto-detects; a diverged solve or a remesh + clears the flag so the next solve cold-starts (with its Picard entry) instead + of warming off a corrupted iterate. + +--- + +## The retired doctrine — do not resurrect it + +An earlier line of work paired the δ-soft-min with a **yield homotopy** and shipped +a model-level enable method for an in-SNES δ-ramp. That API **has been removed from +the source**, and the doctrine it taught rested on a unit-scaling error in the +campaign that motivated it. +Re-measured on the correctly-scaled problem (13 points across two parameter axes): + +- the δ-march **never succeeded where a direct hard-Min solve failed**, and where + both work the direct solve is 4–5× faster with better residuals; +- homotopy rescues **Picard**, not Newton — under the consistent tangent it adds + nothing; +- ramping δ **inside** a single SNES solve is separately proven dead (diverges + `DIVERGED_LINEAR_SOLVE` within ~2 iterations even on the proven config). + +The ruling that closed the campaign: **regularise the PROBLEM (give the shear band +a physical length scale), not the solver.** Where a hard-Min solve will not +converge, sharpening δ is not the missing lever — a viscous seed, the Picard +rescue, and grid sequencing are. + +--- + +## Which tangent for which model (measured) + +`solver.consistent_jacobian` takes `False` | `True` | `"continuation"`: + +| Model | Use | Why | +|-------|-----|-----| +| `ViscoPlasticFlowModel` (non-elastic) | **`True`** (Newton) | Quadratic near the solution; the automatic Picard entry handles the cold start. | +| `ViscoElasticPlasticFlowModel` (VEP) | **`False`** (Picard) | The consistent yield tangent over the elastic stress-history block makes the Jacobian **indefinite → `DIVERGED_LINEAR_SOLVE`**. Picard is contractive. | +| `TransverseIsotropicVEPFlowModel` (TI-VEP) | **`False`** (Picard) | Same as VEP (elastic). | +| Any, far from the solution | **`"continuation"`** | Staged Picard→Newton; Picard locates the basin, Newton finishes. Beat pure Newton at every notch point measured — but its stage switch is a residual LEVEL and one-way, so it can overspend Picard on easy problems. | + +> Measured: VEP loading-through-yield — Picard converges (σ locks at τ_y), +> Newton diverges every step (`DIVERGED_LINEAR_SOLVE`). + +--- + +## Confirm you are actually running Newton + +A consistent-Newton solve on a smooth-enough problem converges **quadratically** — +the residual roughly squares each iteration and reaches ~1e-12 in 3–6 nonlinear +steps. A **linear** tail (a roughly constant reduction factor over ~15–25 steps) +means you are on the Picard tangent — check `solver.consistent_jacobian is True` +and that the viscosity is a function of the unknowns, not a constant. (On genuinely +hard localising problems the quadratic phase may never be reached — that is the +problem, not the tangent; see the doctrine above.) + +Direct symbolic check that the Newton term is present (`dF1/dL` differs between the +frozen and unwrapped flux by exactly the `∂η/∂(grad v)` term): + +```python +import sympy +from underworld3.function.expressions import unwrap_expression +F1 = sympy.Array(stokes.F1.sym) +L = sympy.Array(stokes.Unknowns.L) +G_picard = sympy.derive_by_array(F1, L) +F1_unwrapped = sympy.Array( + [unwrap_expression(e, mode="symbolic_keep_constants") for e in F1], F1.shape) +G_newton = sympy.derive_by_array(F1_unwrapped, L) +# a nonzero difference == the Newton form is present +``` + +--- + +## δ smoothing — a modelling choice, not a convergence strategy (#475 substrate) + +If you want a *rounded* yield law at all (as physics or as a formulation choice), +the substrate is three model properties; δ is a `constants[]` atom, so changing it +never recompiles: + +- **`yield_mode`**: `"min"` (default — exact hard `Min`), `"softmin"` (the + δ-parameterised family below), `"harmonic"` (a **distinct physical model**, a + parallel blend — not an approximation to `Min`). +- **`yield_smoother`**: `"sqrt"` or `"powermean"`. **δ is NOT the same parameter + in the two families**: the power mean's sharpness is `s = 1/(δ + 0.001)`, so + δ ≤ 1 and δ = 1 IS the harmonic mean; the sqrt family's δ is a percentage stress + deviation, generous entry O(10), and δ = 0 is exactly `Min`. The power mean at + δ = 0 lands within 0.07 % of `Min` — an order of magnitude inside a 1e-8 solver + tolerance. +- **`yield_anchor`**: which point is pinned to the exact law — the SIDE of `Min` + belongs to the anchor, not the family. `"onset"` (default, historical) is exact + on the unyielded branch but sits BELOW `Min` at and above yield — a *weaker* + problem than the sharp one. `"yield"` pins τ/τ_y = 1 exactly and sits on-or-above + `Min` everywhere; the cost is stiffer unyielded material (bounded ×2 sqrt, + ×2^δ powermean, both → 1 as δ → 0). + +**If you march δ toward the sharp law, the only sound discipline is multi-solve:** +hold δ constant for a full solve to tolerance, warm-start the next smaller δ, +sharpen only between converged solves. Never ramp δ inside one SNES solve. The +packaged march is `stokes.solve(homotopy=True)` / +`underworld3.systems.yield_continuation` — usable, with two open caveats (#473): +its documented cold-start guarantee does NOT hold on a multi-material +(`Piecewise`) yield stress, so give it a viscous pre-solve anyway; and its +adaptive step control is effectively one-shot (one early decision pins the step +for the whole march). Do not expect it to cross a cliff the direct solve cannot — +measured, it never has. + +--- + +## Floors + +- **`shear_viscosity_min`** (default `-oo` = off) is applied through + `uw.maths.smooth_max`, but the default rounding scale is zero under + `yield_mode="min"` and `δ·|floor|` under the smooth modes — so it **vanishes as + δ → 0**, leaving an exact `Max` corner that kills the consistent tangent + (`nl=0, DIVERGED_LINEAR_SOLVE`). Set **`viscosity_min_rounding`** (a few per + cent of the floor) and the cutoff is differentiable at any δ, including 0. +- A viscosity floor bounds the viscosity contrast and therefore how localised the + solution can be — relaxing it toward zero is a solution-SELECTION continuation, + independent of δ. Use it deliberately. +- **Do not add a strain-rate floor for the cold start.** At `ε̇=0`, `η_pl=+inf` + is carried correctly to the viscous branch by `Min` and by both smooth families; + only a hand-rolled product-over-sum harmonic blend breaks (`inf/inf`) — write it + as `η_ve/(1+f)`. + +--- + +## Failure modes → fixes + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `DIVERGED_LINEAR_SOLVE`, 0 iters, VEP | consistent Newton over the elastic block → indefinite | Picard (`consistent_jacobian=False`) | +| `DIVERGED_LINEAR_SOLVE` at nl=0 with a viscosity floor set | δ→0 leaves the floor's `Max` corner exact | set `viscosity_min_rounding` | +| Newton stalls with no divergence reason | admissible uselessness — steps accepted, residual flat | revert to best iterate, Picard block (`picard=N` / `"continuation"`); consider grid sequencing | +| Converges but σ sits **below** τ_y | a fixed δ>0 soft-min under the default `"onset"` anchor is a WEAKER law | that is the modelling choice you made — use `yield_anchor="yield"`, or δ→0 / `yield_mode="min"` for the exact surface | +| Linear (~20-iter) convergence | Picard tangent when you wanted Newton | `consistent_jacobian=True` on a non-elastic model (see "Confirm" above) | + +--- + +## Gotchas + +- **`./uw build` → `amr-dev` env.** Verify `uw.__file__` is the worktree site-packages. +- **Run VEP tests UNFORKED** — `pytest --forked` SIGABRTs here (fork of multithreaded PETSc). +- `harmonic` yield mode is a **distinct physical model**, not an approximation to Min. +- If you project η, use a **low-order** field (P0/P1) — higher order overshoots and η + is not guaranteed positive. + +--- + +## Reference + +- Yield law: `ViscousFlowModel._combine_yield`, `yield_anchor`, `yield_smoother`, + `viscosity_min_rounding` in `constitutive_models.py`. +- Tangent: `solver.consistent_jacobian` / `_jacobian_source` in + `petsc_generic_snes_solvers.pyx`; design + `docs/developer/design/jacobian-consistent-tangent.md`. +- Warm start / continuation: `docs/developer/design/nonlinear-solver-homotopy-warmstart.md`; + grid sequencing: `docs/developer/design/multilevel-nonlinear-stokes-strategy.md`. +- Tests: `test_0201_solver_has_solution_warmstart.py`, `test_1055_yield_smoother.py`, + `test_1057_yield_homotopy_solve.py`, `test_1059_yield_anchor.py`. +- Solver-config traps, smoother, FMG/multigrid: the `nonlinear-solver` skill. + +Footnote: before this work UW3 differentiated the flux with the viscosity still +wrapped, so `∂η/∂(grad v)` was dropped and viscoplastic solves silently ran the Picard +tangent — the origin of the "~20 iterations is intrinsic" folklore. + +## SNESFAS — do not reach for it + +Nonlinear multigrid (SNESFAS) looks tempting for hard viscoplastic solves but is +**not a viable option** at present (maintainer ruling 2026-07-17): there are no +good preconditioners for the nonlinear hierarchy, and it abandons the robust +linear-solver path (consistent tangent / continuation + fieldsplit + MG) that +this skill is built around. It stays options-only for experiments; treat it as a +future investigation. See `docs/developer/design/solver-strategies-catalogue.md` +and `MULTIGRID_MINIMAL_CONTROL_2026-07.md` (ruling 6). diff --git a/docs/developer/guides/transport-schemes.md b/docs/developer/guides/transport-schemes.md new file mode 100644 index 000000000..d6fa772ba --- /dev/null +++ b/docs/developer/guides/transport-schemes.md @@ -0,0 +1,77 @@ +--- +name: transport-schemes +description: Which transport scheme and which time history to use in Underworld3, and why — nodal, integration-point or grid histories; semi-Lagrangian, Eulerian SUPG or Lagrangian swarm transport; the Courant number to run at; what each choice does to a settled state and to a peak. The evidence is the tests and notes named beside each ruling. +families: [AdvDiffusion, NavierStokes, Eulerian, EulerianSUPG, SemiLagrangian, Lagrangian, Lagrangian_Swarm, IntegrationPointSemiLagrangian] +kind: guide +status: draft, rulings to be confirmed +--- + +# Transport schemes: which one, when + +A time-dependent solve in Underworld3 is a residual plus a history: the +history (`DuDt`, `DFDt`) says where the quantity was at the previous levels +and how it got there. The scheme is the choice of where that history lives +and how it is carried. The classes describe themselves +(`uw.capabilities("histories")`); this page says which to choose. + +## The choices + +| scheme | history lives on | carried by | use for | +|---|---|---|---| +| `Eulerian` | mesh nodes | nothing moves; the transport term is in the residual | diffusion-dominated fields, or with SUPG below | +| `EulerianSUPG` | mesh nodes | implicit advection with streamline-upwind stabilisation, assembled in the residual | the momentum equation of `NavierStokes`; a field advected by a resolved velocity | +| `SemiLagrangian` | mesh nodes | traced back along the flow to a departure point and interpolated there | advection-diffusion at moderate Courant number; the transport the adjoint can differentiate | +| `IntegrationPointSemiLagrangian` | integration points | the same trace, from the quadrature points | stress and other flux histories that must not be smoothed through the nodes | +| `Lagrangian_Swarm` | particles | the particles move; the mesh reads a proxy | material identity, and any history that must follow the material exactly | +| `Symbolic` | nowhere | the user's own expression | a history the script supplies itself | + +## What the tests established + +**Advection of a step (Waters and King, 2026-09-16, #749).** With the +same time integrator, a nodal history converges as the timestep falls; an +integration-point history degrades as the timestep falls, ringing and then +diverging; a grid history diverges at every timestep. The peak of the +profile is set by the integrator, the settled state by the transport. Rule: +for a scalar field carried by the flow, put the history on the nodes. + +**Stress histories (2026-09-11, #735).** For a viscoelastic flux history +the grid and integration-point histories converge together with +resolution; the nodal history converges to a different answer, with about +half again too much stress in the first two cells off a no-slip wall, +because the nodal projection smooths the history through the wall. Rule: +a flux history lives on the integration points (or on the grid, with DEVSS +opted in); a scalar field's history lives on the nodes. The two rules are +not in conflict: they are different quantities. + +**Courant number for the integration-point trace (#703, #737).** The +integration-point semi-Lagrangian scheme runs at Courant number about +one, not below it. Damping it to run at smaller steps was tried and +disliked; the memory term amplifies the low-Courant mode. Refine the mesh +and the timestep together. + +**The momentum equation (#687).** `NavierStokes` carries its momentum +transport as `EulerianSUPG`: implicit advection in the residual, with a +partition-independent cell size in the stabilisation. + +**What the adjoint can differentiate.** A semi-Lagrangian trace is +differentiable in the velocity, and the interpolation at the departure +points is materialised, so a run built on it is adjointable end to end. A +particle step is adjointable exactly when the particle set is fixed across +it, which `swarm.advection` checks by counting. + +## The time integrator is a separate choice + +`order` sets the depth of the history and the scheme's order in time; +`theta` sets the weighting (`0.5` is Crank-Nicolson, `1` backward Euler). +`model.step(dt)` carries the clock, and the coefficients of the scheme are +exact rationals in the residual, so the transcript's key shows the +integrator a part used. A fixed timestep keeps an objective from depending +on the control through the schedule; an adaptive one records its decisions +through `model.rewind(reason=...)`. + +## Rulings still open + +- Whether a nodal history should ever be offered for a flux quantity, or + refused. +- A default Courant target for the nodal semi-Lagrangian scheme, and + whether `estimate_dt()` should report it. diff --git a/docs/developer/guides/uw-visualisation.md b/docs/developer/guides/uw-visualisation.md new file mode 100644 index 000000000..31843102f --- /dev/null +++ b/docs/developer/guides/uw-visualisation.md @@ -0,0 +1,127 @@ +--- +name: uw-visualisation +description: Render Underworld3 mesh fields (T, V, viscosity, the adapted mesh) correctly with PyVista. Use whenever you need to SEE a UW3 result — a field colormap, the moving/adapted mesh, streamlines, or compare runs. Reach for THIS before hand-rolling a renderer; getting the four cosmetic settings wrong makes renders look grey/patchy/blocky and wastes a round-trip with Louis. +families: [] +kind: recipe +--- + +# uw-visualisation + +Canonical PyVista recipe for Underworld3 fields. This exists because every fresh +Claude session re-derives the renderer and gets the colormap / background / +lighting / DOF-sampling wrong, producing "grey/patchy/weird" images Louis +rejects. The settings below match his reference renders exactly. + +**Use PyVista (`underworld3.visualisation`), NOT matplotlib.** Louis reaffirmed +this even after seeing the legacy matplotlib renderer +(`scripts/fault_convection_frames.py`) — that one is NOT preferred. + +## Hard rules (artifacts + output location) + +- **Outputs go under `~/+Simulations/...`, NEVER `/tmp`** (Louis can't view /tmp + or harness task paths). Mirror the run's `--sim-dir`; write `T_.png` into + the run directory, comparison figures into the sim-dir root. +- `pv.OFF_SCREEN = True` at import; finish with `pl.screenshot(path); pl.close()`. + +## The field+mesh pattern (copy this exactly) + +```python +import numpy as np, underworld3 as uw, underworld3.visualisation as vis, pyvista as pv +pv.OFF_SCREEN = True + +mesh = uw.discretisation.Mesh(f"{label}.mesh.00000.h5") # or the live mesh +T = uw.discretisation.MeshVariable("T_v2p1", mesh, 1, degree=3, continuous=True) +T.read_timestep(label, "T_v2p1", 0, outputPath=D) # or use the live var + +pv_T = vis.meshVariable_to_pv_mesh_object(T) # Delaunay through T's OWN DOFs +pv_T.point_data["T"] = np.asarray(T.data[:, 0]) # attach DOF values DIRECTLY (P3-faithful) +edges = vis.mesh_to_pv_mesh(mesh).extract_all_edges() + +pl = pv.Plotter(off_screen=True, window_size=(1000, 1000)) +pl.set_background("white") # rule 2 +pl.add_mesh(pv_T, scalars="T", cmap="RdBu_r", clim=(0, 1), # rules 1 + clim required + show_edges=False, lighting=False) # rule 3 +pl.add_mesh(edges, color="black", line_width=0.5, lighting=False) # mesh overlay +pl.view_xy(); pl.camera.zoom(1.3) +pl.screenshot(out); pl.close() +``` + +## The four things that make renders look bad (all COSMETIC) + +1. `cmap="coolwarm"` → muddy grey-lavender midtone — this IS the "blue/grey/red" + Louis rejects. **Use `cmap="RdBu_r"`** (clean blue→white→red). +2. PyVista's default grey background bleeds through RdBu_r's white (T≈0.5) → dirty + grey. **Always `pl.set_background("white")`.** +3. Default lighting darkens the colormap. **Always `lighting=False`** on every + `add_mesh`. +4. Re-evaluating via `scalar_fn_to_pv_points` / vertex-only sampling drops the + high-order DOFs → blocky. **Attach `T.data[:,0]` directly** to the DOF-cloud + mesh from `meshVariable_to_pv_mesh_object` (it is correct for annulus/box/disc + — do NOT avoid it). `clim` MUST be passed (default `clim=""` trips `np.any`). +5. Resampling ANY field (even P1) onto a regular pixel grid via + `uw.function.evaluate` **dapples at element boundaries** — grid points that + straddle a facet get located into a neighbouring cell with slightly-off + reference coords (Louis: "artefacts across the elements", S-fault rig). + Render derived fields NODALLY on the mesh's own triangulation instead: + evaluate at `mesh_to_pv_mesh(mesh).points` (exact at vertices for P1, + whichever cell the locator picks), attach as point_data, let VTK + interpolate WITHIN elements. On a SPLIT mesh never Delaunay the DOF cloud + (it re-triangulates across the slit) — use the mesh's own cells. + +## Seeing the MESH (adaptation / moving mesh) + +The full-annulus T colormap **washes out mesh detail** — at whole-domain zoom the +grading is invisible. To judge adaptation you MUST crop: + +- Zoom the feature region with a parallel camera: + `pl.camera.parallel_projection = True; pl.camera.parallel_scale = half_width; + pl.camera.focal_point = (cx, cy, 0)`. +- For mesh-only views, drop the field and draw `edges` on white, `line_width≈0.7`. +- Real corruption vs render artifact: apparent "holes / lumps" are often a + mesh-overlay/low-res artifact. Before calling adaptation broken, CHECK the + field's value range is bounded and count folded elements (negative cell area) + programmatically — do NOT diagnose from a render alone. +- **Overlay the feature you're refining to** (a fault trace, an interface): draw it + as a red `pv.PolyData` line over the mesh. Without it you cannot tell whether the + refinement sits ON the feature or has drifted off it (a real failure mode — see + the `adaptive-meshing` skill). Read the geometry from the run manifest so any run + renders the same way. + +## Adaptive / long runs + +- **Render each checkpoint as it lands**, not just the last frame: arm a Monitor that + polls for new `run.mesh.NNNNN.{xdmf,h5}` and emits the index → render on each + event. A completion-only watch leaves you blind for a multi-hour (e.g. TI) run. +- The per-step mesh GEOMETRY must have been written (`write_timestep(..., + meshUpdates=True)`) or you'll render deformed fields on the stale step-0 mesh. + Load the per-step `run.mesh.NNNNN.h5` as the mesh, then `read_timestep` the vars. + +## Velocity + +Same pattern; use **streamlines, not glyphs**. Build a pv mesh for V, add +`pv_mesh.streamlines(...)` or evaluate V on a line seed. Magnitude with the same +white-bg / lighting=False rules. + +## Quantities to judge a convection run (not just pretty pictures) + +- `vrms` from `uw.function.evaluate(V.sym.dot(V.sym), mesh.X.coords)` → the clean + kinetic-energy indicator (more reliable than nodal boundary metrics). +- Surface heat flux Nu via `uw.maths.BdIntegral` on the Upper boundary. +- Mesh quality: fault/bulk nearest-neighbour spacing RATIO (cKDTree) for refinement; + folded-element count + min cell area for tangling. + +## Templates in this skill + +- `render_field.py` — single/`--all`-steps T+mesh render of a run directory. +- `render_field_streamlines.py` — T colormap + mesh + **V streamlines** (sparse + seeds, thin lines, short integration so weak/closed cells read clearly, not + black spiral-blobs). Use for convection. `--tag --all`. +- `zoom_compare.py` — side-by-side cropped mesh+field for N runs at one step. + +Copy these into the run's `scripts/` (or run in place), point `--sim-dir` at the +run, and adjust the field/variable names. They already encode every rule above. + +## Related memory + +`feedback_use_uw_pyvista_visualisation.md`, `feedback_pyvista_viz_pattern.md`, +`feedback_render_all_steps.md`, `project_adaptation_corruption_was_render_artifact.md`. diff --git a/docs/developer/index.md b/docs/developer/index.md index d74b816d4..08b180d12 100644 --- a/docs/developer/index.md +++ b/docs/developer/index.md @@ -44,6 +44,26 @@ same topic are reference or historical material subordinate to the governing doc | Docstring format | NumPy/Sphinx with RST `:math:` — Charter §6 and the [Style Guide docstring section](UW3_Style_and_Patterns_Guide.md) | | Documentation file format | MyST Markdown (`.md`) for Sphinx — CLAUDE.md "Documentation Requests" section | +## Capability guides + +Curated guidance that the code cannot state about itself: which scheme to +choose, which boundary treatment to prefer, how to make a hard solve +converge. Each is one page with front matter naming the families it applies +to, so `uw.capabilities()`, `uw.systems.Stokes.view()` and the MCP server list +it beside the family; the AI skills in `.claude/skills` are symlinks to these +pages, never copies. A change to a family is reviewed against its guides. + +| Guide | Applies to | +|---|---| +| [Transport schemes](guides/transport-schemes.md) | advection-diffusion, Navier-Stokes, the history schemes | +| [Boundary-condition rulings](guides/boundary-condition-rulings.md) | every solver | +| [Nonlinear solver recipe](guides/nonlinear-solver.md) | viscoplastic Stokes | +| [Plasticity solvers](guides/plasticity-solvers.md) | viscoplastic and VEP Stokes | +| [Adaptive meshing](guides/adaptive-meshing.md) | moving-mesh convection | +| [Adapt-on-top faults](guides/adapt-on-top-faults.md) | fault models on adapted meshes | +| [Free-surface convection](guides/free-surface-convection.md) | free-surface Stokes | +| [Visualisation](guides/uw-visualisation.md), [CeTZ figures](guides/cetz-figures.md) | figures | + ## Documentation Structure This documentation is organized into focused sections: @@ -140,6 +160,15 @@ guides/state-as-dataclass guides/BINDER_CONTAINER_SETUP guides/hpc-cluster-setup guides/mpi-hang-supervision +guides/transport-schemes +guides/boundary-condition-rulings +guides/nonlinear-solver +guides/plasticity-solvers +guides/adaptive-meshing +guides/adapt-on-top-faults +guides/free-surface-convection +guides/uw-visualisation +guides/cetz-figures ``` ```{toctree} diff --git a/src/underworld3/constitutive_models.py b/src/underworld3/constitutive_models.py index 6b2499650..44424922a 100644 --- a/src/underworld3/constitutive_models.py +++ b/src/underworld3/constitutive_models.py @@ -759,8 +759,16 @@ def describe_class(cls, depth=4): "text": None, "units": getattr(attr, "units", None), "description": (getattr(attr, "description", "") or "").strip(), "where": []}) + facts = {} + try: + from underworld3.utilities.capabilities import guides_for + linked = guides_for(cls.__name__) + if linked: + facts["guides"] = linked + except Exception: + pass return record("constitutive_model_family", cls.__name__, doc.split("\n")[0], - documentation=doc or None, terms=terms or None) + documentation=doc or None, facts=facts or None, terms=terms or None) def describe(self, depth=4): """What this constitutive model is, as data: its parameters as terms, diff --git a/src/underworld3/cython/petsc_generic_snes_solvers.pyx b/src/underworld3/cython/petsc_generic_snes_solvers.pyx index 6e1255330..7994f2e9f 100644 --- a/src/underworld3/cython/petsc_generic_snes_solvers.pyx +++ b/src/underworld3/cython/petsc_generic_snes_solvers.pyx @@ -1533,6 +1533,13 @@ class SolverBaseClass(uw_object): conditions.append({"mechanism": method, "type": method[4:-3].replace("_", " "), "boundary": "any", "latex": None, "text": (getattr(fn, "__doc__", "") or "").strip().split("\n")[0] or None}) + try: + from underworld3.utilities.capabilities import guides_for + linked = guides_for(cls.__name__, *public) + if linked: + facts["guides"] = linked + except Exception: + pass return record("solver_family", cls.__name__, doc.split("\n")[0], documentation=doc or None, facts=facts, forms=forms or None, terms=terms or None, conditions=conditions or None, terms_declared=bool(terms)) diff --git a/src/underworld3/mcp/__init__.py b/src/underworld3/mcp/__init__.py index 6832b7789..2a4bd460c 100644 --- a/src/underworld3/mcp/__init__.py +++ b/src/underworld3/mcp/__init__.py @@ -334,5 +334,30 @@ def uw_capability(name: str, format: str = "markdown") -> str: return f"error: {exc}" +@server.tool(name="uw_guides", annotations=_READ_ONLY) +def uw_guides() -> str: + """The capability guides in this checkout: curated guidance the code + cannot state about itself (which transport scheme, which boundary + treatment, how to make a hard solve converge), each with the families + it applies to. uw_guide reads one.""" + from ..utilities.capabilities import guides + found = guides() + if not found: + return "no guides found: the server is not running inside an Underworld3 checkout (set UW_DOCS to its docs directory)" + return _yaml([{k: v for k, v in g.items() if k != "path"} for g in found.values()]) + + +@server.tool(name="uw_guide", annotations=_READ_ONLY) +def uw_guide(name: str) -> str: + """One capability guide in full, as Markdown. name is from uw_guides, + such as transport-schemes, boundary-condition-rulings or + nonlinear-solver.""" + from ..utilities.capabilities import guide_text, guides + text = guide_text(name) + if text is None: + return f"error: no guide named {name!r}; guides are {sorted(guides())}" + return text + + def main(): server.run(transport="stdio") diff --git a/src/underworld3/systems/ddt.py b/src/underworld3/systems/ddt.py index fa6645ca6..baf24de11 100644 --- a/src/underworld3/systems/ddt.py +++ b/src/underworld3/systems/ddt.py @@ -572,6 +572,13 @@ def describe_class(cls, depth=4): facts[f"default {key}"] = params[key].default except (TypeError, ValueError): pass + try: + from underworld3.utilities.capabilities import guides_for + linked = guides_for(cls.__name__) + if linked: + facts["guides"] = linked + except Exception: + pass return record("history_family", cls.__name__, doc.split("\n")[0], documentation=doc or None, facts=facts) def describe(self, depth=4): diff --git a/src/underworld3/utilities/capabilities.py b/src/underworld3/utilities/capabilities.py index 2ef7a5d49..073875fa8 100644 --- a/src/underworld3/utilities/capabilities.py +++ b/src/underworld3/utilities/capabilities.py @@ -40,6 +40,76 @@ def families(): return out +def guides_directory(): + """The checkout's ``docs/developer/guides``, found upward from the + working directory or from ``UW_DOCS``; ``None`` outside a checkout.""" + import os + candidates = [] + if os.environ.get("UW_DOCS"): + candidates.append(os.path.join(os.environ["UW_DOCS"], "developer", "guides")) + here = os.path.abspath(os.getcwd()) + while True: + candidates.append(os.path.join(here, "docs", "developer", "guides")) + parent = os.path.dirname(here) + if parent == here: + break + here = parent + for c in candidates: + if os.path.isdir(c): + return c + return None + + +def guides(): + """The capability guides in the checkout: ``{name: {name, description, + families, kind, path}}`` read from each page's front matter. Empty + outside a checkout.""" + import glob + import os + import yaml + directory = guides_directory() + out = {} + if directory is None: + return out + for path in sorted(glob.glob(os.path.join(directory, "*.md"))): + with open(path, encoding="utf-8") as handle: + head = handle.read(4000) + if not head.startswith("---"): + continue + parts = head.split("---", 2) + if len(parts) < 3: + continue + try: + meta = yaml.safe_load(parts[1]) or {} + except yaml.YAMLError: + continue + if not isinstance(meta, dict) or "families" not in meta: + continue + name = str(meta.get("name") or os.path.splitext(os.path.basename(path))[0]) + out[name] = {"name": name, "description": str(meta.get("description") or ""), + "families": [str(f) for f in (meta.get("families") or [])], + "kind": str(meta.get("kind") or "guide"), "path": path} + return out + + +def guides_for(*names): + """The names of the guides whose ``families`` include any of ``names`` + (a public name or a class name).""" + wanted = {str(n) for n in names if n} + return [g["name"] for g in guides().values() if wanted & set(g["families"])] + + +def guide_text(name): + """The body of one guide, front matter removed, or ``None``.""" + g = guides().get(name) + if g is None: + return None + with open(g["path"], encoding="utf-8") as handle: + text = handle.read() + parts = text.split("---", 2) + return parts[2].lstrip("\n") if text.startswith("---") and len(parts) == 3 else text + + def _summary_row(name, description): """A family reduced to what a catalogue line needs.""" facts = dict(description.get("facts") or {}) @@ -51,6 +121,9 @@ def _summary_row(name, description): facts["given"] = [t["name"] for t in description["terms"]] if description.get("conditions"): facts["conditions"] = [c.get("mechanism") for c in description["conditions"]] + linked = guides_for(name, description.get("name")) + if linked: + facts["guides"] = linked return record(description.get("kind", "family"), name, description.get("summary", ""), facts={"class": description.get("name"), **facts}) @@ -91,5 +164,9 @@ def family(name): if cls is None: cls = next((c for c in members.values() if c.__name__ == name), None) if cls is not None: - return cls.describe_class() + d = cls.describe_class() + linked = guides_for(name, cls.__name__) + if linked: + d.setdefault("facts", {})["guides"] = linked + return d return None diff --git a/tests/test_0030_capability_guides.py b/tests/test_0030_capability_guides.py new file mode 100644 index 000000000..b727c9ff3 --- /dev/null +++ b/tests/test_0030_capability_guides.py @@ -0,0 +1,62 @@ +"""Capability guides are documentation, and nothing else copies them. + +Each curated guide is one page under docs/developer/guides with front +matter naming the families it applies to. The AI skills in .claude/skills +are symlinks to those pages, uw.capabilities() lists a guide beside its +family, and a family's class-level description names its guides. This +file enforces the single source: a copied skill, a guide without front +matter, or a family no class carries, fails here. +""" +import os +import pathlib + +import pytest +import yaml + +import underworld3 as uw +from underworld3.utilities.capabilities import families, guides, guide_text, guides_for + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + +ROOT = pathlib.Path(__file__).resolve().parent.parent +GUIDES = ROOT / "docs" / "developer" / "guides" +SKILLS = ROOT / ".claude" / "skills" + + +def _front_matter(path): + text = path.read_text(encoding="utf-8") + assert text.startswith("---"), f"{path.name}: no front matter" + return yaml.safe_load(text.split("---", 2)[1]) or {} + + +def test_every_skill_is_a_symlink_into_the_guides(): + skills = sorted(p for p in SKILLS.glob("*/SKILL.md")) + assert skills, "no skills found" + for skill in skills: + assert skill.is_symlink(), f"{skill} is a copy; make it a symlink into docs/developer/guides" + target = (skill.parent / os.readlink(skill)).resolve() + assert target.parent == GUIDES.resolve() and target.exists(), (skill, target) + + +def test_every_guide_names_real_families(): + known = {name for group in families().values() for name in group} + known |= {cls.__name__ for group in families().values() for cls in group.values()} + found = guides() + assert {"transport-schemes", "boundary-condition-rulings", "nonlinear-solver"} <= set(found) + for name, g in found.items(): + meta = _front_matter(pathlib.Path(g["path"])) + assert meta.get("name") == name and meta.get("description"), name + unknown = set(g["families"]) - known + assert not unknown, f"guide {name} names families no class carries: {sorted(unknown)}" + + +def test_families_list_their_guides(): + assert "boundary-condition-rulings" in guides_for("Stokes") + assert "transport-schemes" in guides_for("SemiLagrangian") + stokes = uw.systems.Stokes.describe_class() + assert "nonlinear-solver" in stokes["facts"]["guides"] + cat = uw.capabilities("solvers") + row = next(c for c in cat["children"][0]["children"] if c["name"] == "Stokes") + assert "boundary-condition-rulings" in row["facts"]["guides"] + text = guide_text("transport-schemes") + assert text.startswith("# Transport schemes") and guide_text("nothing") is None From fb634f399acfe61749beb42c0d3a56eaa1462a6e Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sun, 27 Sep 2026 11:25:36 -0700 Subject: [PATCH 7/9] mesh.view: is_notebook is a function, not a flag The level-0 view tested uw.is_notebook as a flag, so every serial run tried to plot the mesh through pyvista; on CI that import fails once another test has touched matplotlib (test_0017, test_view_prints_outside_a_notebook). The same line is fixed in #792; taken here so this PR is green on its own. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V --- src/underworld3/discretisation/discretisation_mesh.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/underworld3/discretisation/discretisation_mesh.py b/src/underworld3/discretisation/discretisation_mesh.py index 2c91a9992..e9a58eb6d 100644 --- a/src/underworld3/discretisation/discretisation_mesh.py +++ b/src/underworld3/discretisation/discretisation_mesh.py @@ -1823,7 +1823,7 @@ def view(self, level=0, format=None): from underworld3.utilities.describe import view as _view if uw.mpi.rank == 0: _view(self, format=format) - if uw.is_notebook and uw.mpi.size == 1: + if uw.is_notebook() and uw.mpi.size == 1: uw.visualisation.plot_mesh(self, window_size=(600, 400)) elif level == 1: if uw.mpi.rank == 0: From b4d0b2f2b2a6678ef894d6d14377900f3da1880b Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sun, 27 Sep 2026 11:49:55 -0700 Subject: [PATCH 8/9] Review fixes for the description and transcript stack From the adversarial review of the stack before merging. Three must-fixes: mesh.view() described on rank 0 only while the cell-quality summary in describe() is a collective, so a two-rank view deadlocked; every rank now describes and the renderer prints on rank 0. Part records were written again every step whenever the timestep changed, since each history's \Delta t is a run-time constant; the timesteps are left out beside the clock, and a record is written again only when a parameter changes. Swarm.__init__ had lost its timing decorator to the describe() inserted between them. And the rest: displaying an instance renders its description rather than a subclass view with side effects (a mesh no longer plots itself when evaluated in a cell); a fact that said the same for every solver family is gone; Model.view honours show_materials; the guides are read once per change of the directory rather than per class description; the constants in a part record are documented, with their frame. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V --- docs/developer/subsystems/transcript-query.md | 12 ++++++++++++ .../cython/petsc_generic_snes_solvers.pyx | 14 ++++++-------- .../discretisation/discretisation_mesh.py | 5 +++-- src/underworld3/model.py | 5 ++++- src/underworld3/swarm.py | 2 +- src/underworld3/utilities/_api_tools.py | 4 ++++ src/underworld3/utilities/capabilities.py | 15 ++++++++++++--- src/underworld3/utilities/describe.py | 8 ++++++++ 8 files changed, 50 insertions(+), 15 deletions(-) diff --git a/docs/developer/subsystems/transcript-query.md b/docs/developer/subsystems/transcript-query.md index a250257be..ceba2883b 100644 --- a/docs/developer/subsystems/transcript-query.md +++ b/docs/developer/subsystems/transcript-query.md @@ -49,6 +49,18 @@ The note then carries `reason` and `detail`, and `t.backtracks()` returns them. A step abandoned by an exception records the exception's class and message as `abandoned_by` without anything from the caller. +## When a part is recorded again + +A part records its description when it first acts and again when its form +changes. Its record also carries `constants`: the run-time constants the +solver packed for that solve, by name, as the nondimensional values the +kernel reads (a run with units stores the scaled numbers here and the +dimensional values under `terms`). When any of them changes between +solves the part is recorded again, since a parameter changed without a +rebuild is still a different equation; the model clock and the timesteps +are left out, so a run with an adaptive step does not re-record every +step. `t.part(name, at_step=i)` returns the record in force at step `i`. + ## The same tree as everything else `t.describe()` is a record in the shape every object uses (see diff --git a/src/underworld3/cython/petsc_generic_snes_solvers.pyx b/src/underworld3/cython/petsc_generic_snes_solvers.pyx index 7994f2e9f..d4227e83d 100644 --- a/src/underworld3/cython/petsc_generic_snes_solvers.pyx +++ b/src/underworld3/cython/petsc_generic_snes_solvers.pyx @@ -1504,11 +1504,6 @@ class SolverBaseClass(uw_object): if any(b.__name__ == base for b in cls.__mro__): facts["unknown"] = what break - try: - params = inspect.signature(cls.__init__).parameters - facts["time dependent"] = any(p in params for p in ("DuDt", "DFDt", "order")) - except (TypeError, ValueError): - pass forms = {} for name in ("F0", "F1", "PF0"): declared = None @@ -2644,8 +2639,10 @@ class SolverBaseClass(uw_object): # exactly as implemented. The run-time constants go with it: # a parameter changed between solves does not rebuild the # kernel, so its new value is what tells the record the - # equation is not the one it holds. The clock is left out, - # or a time-dependent run would re-record every step. + # equation is not the one it holds. The clock and the + # timesteps (the solver's and each history's \Delta t) are + # left out, or a time-dependent run with an adaptive step + # would re-record the whole description every step. constants = None try: from underworld3.utilities._jitextension import _pack_constants @@ -2653,7 +2650,8 @@ class SolverBaseClass(uw_object): packed = _pack_constants(self.constants_manifest) constants = {str(getattr(expr, "name", index)): float(packed[index]) for index, expr in self.constants_manifest - if expr is not clock} + if expr is not clock + and not str(getattr(expr, "name", "")).startswith("\\Delta t")} except Exception: constants = None model._describe_part(self, part, label, constants=constants) diff --git a/src/underworld3/discretisation/discretisation_mesh.py b/src/underworld3/discretisation/discretisation_mesh.py index e9a58eb6d..ee514db15 100644 --- a/src/underworld3/discretisation/discretisation_mesh.py +++ b/src/underworld3/discretisation/discretisation_mesh.py @@ -1820,9 +1820,10 @@ def view(self, level=0, format=None): import numpy as np if level == 0: + # every rank describes (the cell-quality summary is a collective); + # the renderer prints on rank 0 only from underworld3.utilities.describe import view as _view - if uw.mpi.rank == 0: - _view(self, format=format) + _view(self, format=format) if uw.is_notebook() and uw.mpi.size == 1: uw.visualisation.plot_mesh(self, window_size=(600, 400)) elif level == 1: diff --git a/src/underworld3/model.py b/src/underworld3/model.py index 0bc8adb94..b87841839 100644 --- a/src/underworld3/model.py +++ b/src/underworld3/model.py @@ -5671,7 +5671,10 @@ def view(self, verbose: int = 0, show_materials: bool = True, show_petsc: bool = notebook or a terminal, or in the ``format`` named. ``verbose`` adds a level of contained objects per unit.""" from underworld3.utilities.describe import view as _view - _view(self, format=format, depth=1 + int(verbose)) + description = self.describe(depth=1 + int(verbose)) + if not show_materials: + (description.get("facts") or {}).pop("materials", None) + _view(description, format=format, depth=1 + int(verbose)) if show_petsc: try: self.mesh.dm.view() diff --git a/src/underworld3/swarm.py b/src/underworld3/swarm.py index f15703e9e..d43726393 100644 --- a/src/underworld3/swarm.py +++ b/src/underworld3/swarm.py @@ -3309,7 +3309,6 @@ class Swarm(Stateful, uw_object): instances = 0 - @timing.routine_timer_decorator def describe(self, depth=4): """What this swarm is, as data: its particle count and its mesh, with its variables as children.""" @@ -3335,6 +3334,7 @@ def describe(self, depth=4): if "particles on this rank" in facts else "") return record("swarm", getattr(self, "name", None), summary, facts=facts, children=children) + @timing.routine_timer_decorator def __init__(self, mesh, recycle_rate=0, verbose=False, clip_to_mesh=True): # Particle recycling (streak swarms) was excised in 2026-07: the # machinery had been broken (NameError) and untested for some time diff --git a/src/underworld3/utilities/_api_tools.py b/src/underworld3/utilities/_api_tools.py index 3b3b79f33..9b661d1d7 100644 --- a/src/underworld3/utilities/_api_tools.py +++ b/src/underworld3/utilities/_api_tools.py @@ -552,6 +552,10 @@ def _ipython_display_(self_or_cls): else: print(rendered) return + if type(self_or_cls).describe is not uw_object.describe: + from .describe import view as _view + _view(self_or_cls) # the description, without a subclass view's side effects + return self_or_cls.view() # View is similar but we can give it arguments to force the diff --git a/src/underworld3/utilities/capabilities.py b/src/underworld3/utilities/capabilities.py index 073875fa8..aa6527042 100644 --- a/src/underworld3/utilities/capabilities.py +++ b/src/underworld3/utilities/capabilities.py @@ -60,10 +60,13 @@ def guides_directory(): return None +_GUIDES_CACHE = {} + + def guides(): """The capability guides in the checkout: ``{name: {name, description, families, kind, path}}`` read from each page's front matter. Empty - outside a checkout.""" + outside a checkout. Read once per change of the directory's contents.""" import glob import os import yaml @@ -71,7 +74,12 @@ def guides(): out = {} if directory is None: return out - for path in sorted(glob.glob(os.path.join(directory, "*.md"))): + paths = sorted(glob.glob(os.path.join(directory, "*.md"))) + stamp = tuple((p, os.path.getmtime(p)) for p in paths) + cached = _GUIDES_CACHE.get(directory) + if cached is not None and cached[0] == stamp: + return dict(cached[1]) + for path in paths: with open(path, encoding="utf-8") as handle: head = handle.read(4000) if not head.startswith("---"): @@ -89,7 +97,8 @@ def guides(): out[name] = {"name": name, "description": str(meta.get("description") or ""), "families": [str(f) for f in (meta.get("families") or [])], "kind": str(meta.get("kind") or "guide"), "path": path} - return out + _GUIDES_CACHE[directory] = (stamp, out) + return dict(out) def guides_for(*names): diff --git a/src/underworld3/utilities/describe.py b/src/underworld3/utilities/describe.py index 5b8429e8d..d20bb9392 100644 --- a/src/underworld3/utilities/describe.py +++ b/src/underworld3/utilities/describe.py @@ -379,7 +379,15 @@ def view(target, format=None, depth=None, **describe_kwargs): """Show a description: ``target`` is an object with ``describe()`` or a description already made. With no ``format``, Markdown with mathematics in a notebook and plain text elsewhere; a named format prints it.""" + # describe() on every rank, since a description may take a collective + # (a mesh's cell quality); output from rank 0 only description = target.describe(**describe_kwargs) if hasattr(target, "describe") else target + try: + from underworld3 import mpi + if getattr(mpi, "rank", 0) != 0: + return + except ImportError: + pass if format is None: from underworld3.utilities.docstring_utils import in_jupyter if in_jupyter(): From 6d5fc813b639a927dcad70a7fb5a2180cbc7a11c Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sun, 27 Sep 2026 13:09:02 -0700 Subject: [PATCH 9/9] State the sanctioned failure on each bare except, for the Charter S4 scan Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V --- src/underworld3/discretisation/enhanced_variables.py | 2 +- src/underworld3/systems/ddt.py | 4 ++-- src/underworld3/utilities/describe.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/underworld3/discretisation/enhanced_variables.py b/src/underworld3/discretisation/enhanced_variables.py index 7b63211ad..19f58d22e 100644 --- a/src/underworld3/discretisation/enhanced_variables.py +++ b/src/underworld3/discretisation/enhanced_variables.py @@ -255,7 +255,7 @@ def describe(self, depth=4): if self.has_units: facts["dimensionality"] = str(self.dimensionality) except Exception: - pass + pass # a variable whose units cannot be read is described without them facts["persistent"] = bool(getattr(self, "_persistent", False)) return d diff --git a/src/underworld3/systems/ddt.py b/src/underworld3/systems/ddt.py index baf24de11..cc796eecd 100644 --- a/src/underworld3/systems/ddt.py +++ b/src/underworld3/systems/ddt.py @@ -571,14 +571,14 @@ def describe_class(cls, depth=4): if key in params and params[key].default is not inspect.Parameter.empty: facts[f"default {key}"] = params[key].default except (TypeError, ValueError): - pass + pass # a class whose signature cannot be inspected reports no defaults try: from underworld3.utilities.capabilities import guides_for linked = guides_for(cls.__name__) if linked: facts["guides"] = linked except Exception: - pass + pass # outside a checkout there are no guides to list return record("history_family", cls.__name__, doc.split("\n")[0], documentation=doc or None, facts=facts) def describe(self, depth=4): diff --git a/src/underworld3/utilities/describe.py b/src/underworld3/utilities/describe.py index d20bb9392..07382d68e 100644 --- a/src/underworld3/utilities/describe.py +++ b/src/underworld3/utilities/describe.py @@ -93,7 +93,7 @@ def plain(value): if isinstance(value, np.ndarray): return value.tolist() except ImportError: - pass + pass # without numpy there are no array leaves to convert return str(value) @@ -387,7 +387,7 @@ def view(target, format=None, depth=None, **describe_kwargs): if getattr(mpi, "rank", 0) != 0: return except ImportError: - pass + pass # outside MPI every process is rank 0 if format is None: from underworld3.utilities.docstring_utils import in_jupyter if in_jupyter():