Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/developer/guides/mcp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ pixi run -e <env> 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.
Expand Down
30 changes: 30 additions & 0 deletions docs/developer/subsystems/describe-and-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,36 @@ 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.

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

Override `describe(self, depth=4)` and return a record built with
Expand Down
3 changes: 2 additions & 1 deletion src/underworld3/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions src/underworld3/constitutive_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions src/underworld3/cython/petsc_generic_snes_solvers.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -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``).
Expand Down Expand Up @@ -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.

Expand Down
33 changes: 33 additions & 0 deletions src/underworld3/mcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,5 +301,38 @@ def uw_describe_render(record_yaml: str, format: str = "markdown", depth: int =
return f"error: {exc}"


@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 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)
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."""
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():
server.run(transport="stdio")
17 changes: 17 additions & 0 deletions src/underworld3/systems/ddt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
36 changes: 23 additions & 13 deletions src/underworld3/utilities/_api_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
95 changes: 95 additions & 0 deletions src/underworld3/utilities/capabilities.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading