#
# Flatten / unflatten protocol for expression trees (pytree-style)
#
from __future__ import annotations
import functools
from collections.abc import Callable, Mapping, MutableMapping, Sequence
from typing import Any
import numpy as np
import pybamm
# How the leaves of one leaf field are stored on the node
_ONE = "one" # a single symbol
_SEQ = "seq" # a list / tuple of symbols
# Derived values that are rebuilt on demand: never stored state, never identity
_TRANSIENT_SLOTS = frozenset(
{
"_id",
"_cached_shape",
"_cached_size",
"_cached_is_constant",
"_saved_evaluate_for_shape",
"_saved_evaluates_on_edges",
}
)
# Stored state that does not take part in identity: display labels, memo caches
# and discretisation annotations
_NON_IDENTITY_SLOTS = frozenset(
{
"_print_name",
"_raw_print_name",
"_mesh",
"_secondary_mesh",
"_tertiary_mesh",
"_disc_state_vector",
}
)
@functools.cache
def layout(cls: type) -> tuple[tuple[str, ...], tuple[str, ...], tuple[str, ...]]:
"""
The single description of how a symbol class is put together, computed once
per class from the ``__slots__``, ``_leaf_fields`` and ``_id_excluded_fields``
declared along its MRO.
Returns
-------
tuple
``(leaf_fields, state_fields, identity_fields)``: the slots holding
symbols this node depends on (children first), every other slot that is
stored state, and the subset of those that define the node's identity.
"""
# declarations accumulate along the MRO, base classes first
slots: list[str] = []
declared_leaves: list[str] = []
excluded: set[str] = set(_NON_IDENTITY_SLOTS)
for klass in reversed(cls.__mro__):
declared_slots = getattr(klass, "__slots__", ())
if isinstance(declared_slots, str):
declared_slots = (declared_slots,)
for slot in declared_slots:
if slot not in slots and slot != "__weakref__":
slots.append(slot)
for field in klass.__dict__.get("_leaf_fields", ()):
if field not in declared_leaves:
declared_leaves.append(field)
excluded.update(klass.__dict__.get("_id_excluded_fields", ()))
leaf_fields = ("_children", *declared_leaves)
state_fields = tuple(
slot
for slot in slots
if slot not in leaf_fields and slot not in _TRANSIENT_SLOTS
)
identity_fields = tuple(f for f in state_fields if f not in excluded)
return leaf_fields, state_fields, identity_fields
def _hashable(value: Any):
"""
A hashable stand-in for a static field value. Numbers become their ``str``:
``hash(-1) == hash(-2)`` in CPython, so raw numbers would let distinct values
share an id.
"""
if type(value) is str or type(value) is bool or value is None:
return value
if type(value) is pybamm.expression_tree.symbol.Domains:
return value # interned and pre-hashed
if isinstance(value, np.generic):
value = value.item()
if isinstance(value, int | float | complex):
return str(value)
if isinstance(value, dict):
return tuple((key, _hashable(item)) for key, item in value.items())
if isinstance(value, list | tuple):
return tuple(_hashable(item) for item in value)
if isinstance(value, slice):
return (slice, value.start, value.stop, value.step)
if isinstance(value, np.ndarray): # hash like the equivalent nested lists
return _hashable(value.tolist())
return value
def leaves_of(symbol: pybamm.Symbol) -> Sequence[pybamm.Symbol]:
"""The symbols ``symbol`` directly depends on, children first."""
leaf_fields = layout(type(symbol))[0]
if len(leaf_fields) == 1:
return symbol._children
leaves = list(symbol._children)
for field in leaf_fields[1:]:
value = getattr(symbol, field, None)
if isinstance(value, pybamm.Symbol):
leaves.append(value)
elif isinstance(value, list | tuple) and all(
isinstance(item, pybamm.Symbol) for item in value
):
leaves.extend(value)
return leaves
def identity_key(symbol: pybamm.Symbol, leaf_ids: Sequence[int]) -> tuple:
"""
Everything that defines a symbol's identity: its class, its identity-bearing
static fields and the ids of its leaves.
"""
cls = type(symbol)
domains_type = pybamm.expression_tree.symbol.Domains
key = [cls]
for field in layout(cls)[2]:
value = getattr(symbol, field, None)
value_type = type(value)
# most fields are names and domains: skip the generic conversion
if value_type is str or value_type is domains_type or value is None:
key.append(value)
else:
key.append(_hashable(value))
key.extend(leaf_ids)
return tuple(key)
[docs]
class TreeDef:
"""
The static structure of a symbol: everything needed to rebuild it from its
leaves. Returned by :func:`tree_flatten` and consumed by :func:`tree_unflatten`.
Parameters
----------
node_type : type
The symbol class.
n_children : int
How many leading leaves are the node's children.
children_container : type
``list`` or ``tuple``, whichever the node stores its children in.
leaf_spec : tuple
``(field, kind, count, container)`` per extra leaf field, in order.
state : dict
The node's remaining (non-leaf, non-derived) state.
"""
__slots__ = ("children_container", "leaf_spec", "n_children", "node_type", "state")
def __init__(self, node_type, n_children, children_container, leaf_spec, state):
self.node_type = node_type
self.n_children = n_children
self.children_container = children_container
self.leaf_spec = leaf_spec
self.state = state
@property
def num_leaves(self) -> int:
return self.n_children + sum(count for _, _, count, _ in self.leaf_spec)
def __repr__(self):
fields = ", ".join(field for field, _, _, _ in self.leaf_spec)
return (
f"TreeDef({self.node_type.__name__}, children={self.n_children}"
+ (f", leaves=[{fields}]" if fields else "")
+ ")"
)
[docs]
def tree_flatten(symbol: pybamm.Symbol) -> tuple[tuple[pybamm.Symbol, ...], TreeDef]:
"""
Split a symbol into its leaves (children and the other symbols it depends
on) and a :class:`TreeDef` describing how to put it back together.
"""
leaf_fields = layout(type(symbol))[0]
state = symbol.__getstate__()
leaves: list = list(state.pop("_children"))
spec: list = []
for field in leaf_fields[1:]:
# a field that is unset, None or holds plain numbers stays in the state
value = state.get(field)
if isinstance(value, pybamm.Symbol):
leaves.append(state.pop(field))
spec.append((field, _ONE, 1, None))
elif isinstance(value, list | tuple) and all(
isinstance(item, pybamm.Symbol) for item in value
):
leaves.extend(state.pop(field))
spec.append((field, _SEQ, len(value), type(value)))
return tuple(leaves), TreeDef(
type(symbol), len(symbol._children), type(symbol._children), tuple(spec), state
)
[docs]
def tree_unflatten(treedef: TreeDef, leaves: Sequence[pybamm.Symbol]) -> pybamm.Symbol:
"""
Rebuild a symbol from a :class:`TreeDef` and its leaves, without
simplification or validation. The result has no id yet.
"""
if len(leaves) != treedef.num_leaves:
raise ValueError(
f"{treedef} expects {treedef.num_leaves} leaves, got {len(leaves)}"
)
state = dict(treedef.state)
state["_children"] = treedef.children_container(leaves[: treedef.n_children])
position = treedef.n_children
for field, kind, count, container in treedef.leaf_spec:
if kind == _ONE:
state[field] = leaves[position]
else:
state[field] = container(leaves[position : position + count])
position += count
new_symbol = object.__new__(treedef.node_type)
new_symbol.__setstate__(state)
return new_symbol
def _same(new, old) -> bool:
"""Whether a rewritten leaf is interchangeable with the original."""
return new is old or new == old
[docs]
def rebuild(
node: pybamm.Symbol,
new_leaves: Sequence[pybamm.Symbol],
simplify: bool = True,
) -> pybamm.Symbol:
"""
Return ``node`` with its leaves swapped for ``new_leaves``.
``node`` itself is returned when no leaf changed, so untouched subtrees are
shared rather than copied. When only children changed the node's constructor
is used (so derived names, domains and, if ``simplify``, simplifications are
applied); otherwise the structure is rebuilt as-is with
:func:`tree_unflatten`.
"""
leaves = leaves_of(node)
if len(new_leaves) != len(leaves):
raise ValueError(
f"{type(node).__name__} expects {len(leaves)} leaves, got {len(new_leaves)}"
)
# equal ids mean interchangeable symbols, so an equal leaf is "unchanged"
if all(map(_same, new_leaves, leaves)):
return node
n_children = len(node._children)
new_children = list(new_leaves[:n_children])
template = node
if not all(map(_same, new_leaves[n_children:], leaves[n_children:])):
# install the other rewritten leaves first, so the constructor sees them all
_, treedef = tree_flatten(node)
template = tree_unflatten(
treedef, [*leaves[:n_children], *new_leaves[n_children:]]
)
if all(map(_same, new_children, leaves[:n_children])):
return template
# the constructor derives names, domains and simplifications from the inputs
return template.create_copy(
new_children=new_children, perform_simplifications=simplify
)
[docs]
def tree_map(
fn: Callable[[pybamm.Symbol, tuple[pybamm.Symbol, ...]], pybamm.Symbol],
tree: pybamm.Symbol,
cache: MutableMapping[pybamm.Symbol, pybamm.Symbol] | None = None,
is_leaf: Callable[[pybamm.Symbol], bool] | None = None,
) -> pybamm.Symbol:
"""
Rewrite an expression tree bottom-up, out of place.
Parameters
----------
fn : callable
``fn(node, new_leaves)`` returns the node to use in place of ``node``, given
the already-rewritten leaves. Use :func:`rebuild` for the default
"same node with new leaves" behaviour.
tree : :class:`pybamm.Symbol`
The root of the expression tree to rewrite.
cache : dict, optional
Memo of ``{original node: rewritten node}``. Shared nodes are rewritten
once; pass the same dict across calls to share work between trees.
is_leaf : callable, optional
``is_leaf(node)`` returning True stops the descent at ``node``: ``fn`` is
then called as ``fn(node, ())`` with nothing below it rewritten.
Returns
-------
The rewritten symbol. ``tree`` is not modified.
"""
if not isinstance(tree, pybamm.Symbol):
raise TypeError("tree must be a pybamm.Symbol")
if cache is None:
cache = {}
def postprocess(node, _leaves, new_leaves):
return fn(node, new_leaves)
_fold(tree, cache, postprocess, is_leaf=is_leaf)
return cache[tree]
def _fold(tree, cache, postprocess, is_leaf=None):
"""
Iterative post-order fold for :func:`tree_map`.
Each node is expanded once: its leaves are computed once and kept on the
stack, then rewritten in one go when all of them are in ``cache``.
"""
stack = [(tree, None)]
while stack:
node, leaves = stack[-1]
if leaves is None:
if node in cache:
stack.pop()
continue
if is_leaf is not None and is_leaf(node):
cache[node] = postprocess(node, (), ())
stack.pop()
continue
leaves = leaves_of(node)
# children first, left to right
pending = [leaf for leaf in reversed(leaves) if leaf not in cache]
if pending:
stack[-1] = (node, leaves)
stack.extend((leaf, None) for leaf in pending)
continue
stack.pop()
new_leaves = tuple(cache[leaf] for leaf in leaves)
cache[node] = postprocess(node, tuple(leaves), new_leaves)
[docs]
def replace(
tree: pybamm.Symbol,
mapping: Mapping[pybamm.Symbol, Any],
cache: MutableMapping[pybamm.Symbol, pybamm.Symbol] | None = None,
simplify: bool = True,
) -> pybamm.Symbol:
"""
Substitute symbols throughout an expression tree, out of place.
Every node equal (by id) to a key of ``mapping`` is replaced by the
corresponding value, which is not itself traversed. All other nodes are
rebuilt with :func:`rebuild` only if something below them changed, so the
result shares every untouched subtree with ``tree``.
Parameters
----------
tree : :class:`pybamm.Symbol`
The root of the expression tree to substitute into.
mapping : dict
``{symbol: replacement}``; replacements may be numbers.
cache : dict, optional
Memo shared between calls made with the same ``mapping``.
simplify : bool, optional
Whether rebuilt operators apply their usual simplifications (default True).
"""
if not isinstance(tree, pybamm.Symbol):
raise TypeError("tree must be a pybamm.Symbol")
if not mapping:
return tree # nothing to substitute: the tree is returned untouched
replacements = dict(mapping) # values are converted to symbols when first used
if cache is None:
cache = {}
def rebuild_replaced(node, leaves, new_leaves):
if node in replacements:
return pybamm.convert_to_symbol(replacements[node])
return rebuild(node, new_leaves, simplify)
_fold(tree, cache, rebuild_replaced, is_leaf=replacements.__contains__)
return cache[tree]