From cf37617fa8ae4584c3f7a3a9acea93fa0c38893e Mon Sep 17 00:00:00 2001 From: Pablo Brubeck Date: Fri, 10 Jul 2026 22:56:25 +0100 Subject: [PATCH 01/34] Tabulate high-order expansion derivatives by recurrence --- FIAT/expansions.py | 92 +++++++++++++++++++++---------- test/FIAT/unit/test_polynomial.py | 25 +++++++++ 2 files changed, 89 insertions(+), 28 deletions(-) diff --git a/FIAT/expansions.py b/FIAT/expansions.py index 3049d44f0..93dd5c969 100644 --- a/FIAT/expansions.py +++ b/FIAT/expansions.py @@ -62,6 +62,55 @@ def jacobi_factors(x, y, z, dx, dy, dz): return fa, fb, fc, dfa, dfb, dfc +def _product_derivative_term(factor: object, + operand: numpy.ndarray, + factor_axes: tuple[int, ...], + rank: int) -> numpy.ndarray: + """Return one Leibniz term in an ordered derivative tensor.""" + factor = numpy.asarray(factor) + factor_rank = len(factor_axes) + operand_rank = rank - factor_rank + factor_index = (slice(None),) * factor_rank + (None,) * operand_rank + (Ellipsis,) + operand_index = (None,) * factor_rank + (Ellipsis,) + product = factor[factor_index] * operand[operand_index] + + if rank: + dim = factor.shape[0] if operand_rank == 0 else operand.shape[0] + factor_source = {axis: source for source, axis in enumerate(factor_axes)} + operand_axes = tuple(axis for axis in range(rank) if axis not in factor_axes) + operand_source = {axis: factor_rank + source + for source, axis in enumerate(operand_axes)} + permutation = tuple((factor_source | operand_source)[axis] + for axis in range(rank)) + product = product.transpose(permutation + tuple(range(rank, product.ndim))) + product = product.reshape((dim,) * rank + operand.shape[operand_rank:]) + + return product + + +def _product_derivative(factor: object, + dfactor: object | None, + ddfactor: object | None, + operands: list[numpy.ndarray], + rank: int) -> numpy.ndarray: + """Differentiate a recurrence factor times a basis derivative tensor.""" + # The Dubiner recurrence factors are linear or quadratic, so higher + # factor derivatives vanish and the Leibniz sum stops at order two. + result = _product_derivative_term(factor, operands[rank], (), rank) + + if dfactor is not None and rank >= 1: + for axis in range(rank): + result += _product_derivative_term(dfactor, operands[rank-1], (axis,), rank) + + if ddfactor is not None and rank >= 2: + for axis0 in range(rank): + for axis1 in range(axis0 + 1, rank): + result += _product_derivative_term(ddfactor, operands[rank-2], + (axis0, axis1), rank) + + return result + + def dubiner_recurrence(dim, n, order, ref_pts, Jinv, scale, variant=None): """Tabulate a Dubiner expansion set using the recurrence from (Kirby 2010). @@ -77,8 +126,6 @@ def dubiner_recurrence(dim, n, order, ref_pts, Jinv, scale, variant=None): :returns: A tuple with tabulations of the expansion set and its derivatives. """ - if order > 2: - raise ValueError("Higher order derivatives not supported") if variant not in [None, "bubble", "dual"]: raise ValueError(f"Invalid variant {variant}") if variant == "bubble": @@ -95,7 +142,7 @@ def dubiner_recurrence(dim, n, order, ref_pts, Jinv, scale, variant=None): results = [numpy.zeros((num_members,) + (dim,)*k + phi0.shape[1:], dtype=phi0.dtype) for k in range(order+1)] - phi, dphi, ddphi = results + [None] * (2-order) + phi = results[0] phi[0] = scale if dim == 0 or n == 0: return results @@ -127,14 +174,12 @@ def dubiner_recurrence(dim, n, order, ref_pts, Jinv, scale, variant=None): fcur = a * fa - b * fb phi[inext] = fcur * phi[icur] - if dphi is not None: + if order: dfcur = a * dfa - b * dfb - dphi[inext] = phi[icur] * dfcur - dphi[inext] += fcur * dphi[icur] - if ddphi is not None: - ddphi[inext] = outer(dphi[icur], dfcur) - ddphi[inext] += outer(dfcur, dphi[icur]) - ddphi[inext] += fcur * ddphi[icur] + cur = [result[icur] for result in results] + for rank in range(1, order+1): + results[rank][inext] = _product_derivative(fcur, dfcur, None, + cur, rank) # general i by recurrence for i in range(1, n - sum(sub_index)): @@ -145,26 +190,17 @@ def dubiner_recurrence(dim, n, order, ref_pts, Jinv, scale, variant=None): fprev = -c * fc phi[inext] = fcur * phi[icur] phi[inext] += fprev * phi[iprev] - if dphi is None: - continue dfcur = a * dfa - b * dfb dfprev = -c * dfc - dphi[inext] = phi[icur] * dfcur - dphi[inext] += phi[iprev] * dfprev - dphi[inext] += fcur * dphi[icur] - dphi[inext] += fprev * dphi[iprev] - if ddphi is None: - continue - ddfprev = -c * ddfc - ddphi[inext] = phi[iprev] * ddfprev - ddphi[inext] += outer(dphi[icur], dfcur) - ddphi[inext] += outer(dfcur, dphi[icur]) - ddphi[inext] += outer(dphi[iprev], dfprev) - ddphi[inext] += outer(dfprev, dphi[iprev]) - ddphi[inext] += fcur * ddphi[icur] - ddphi[inext] += fprev * ddphi[iprev] + cur = [result[icur] for result in results] + prev = [result[iprev] for result in results] + for rank in range(1, order+1): + results[rank][inext] = _product_derivative(fcur, dfcur, None, + cur, rank) + results[rank][inext] += _product_derivative(fprev, dfprev, ddfprev, + prev, rank) # normalize d = codim + 1 @@ -291,7 +327,7 @@ def __init__(self, ref_el, scale=None, variant=None): self.scale = scale self.variant = variant self.continuity = "C0" if variant == "bubble" else None - self.recurrence_order = 2 + self.recurrence_order = math.inf self._dmats_cache = {} self._cell_node_map_cache = {} @@ -353,7 +389,7 @@ def _tabulate_on_cell(self, n, pts, order=0, cell=0, direction=None): def distance(alpha, beta): return sum(ai != bi for ai, bi in zip(alpha, beta)) - # Only use dmats if tabulate failed + # Use dmats only for derivatives above the configured recurrence order. for i in range(len(phi), order + 1): dmats = self.get_dmats(n, cell=cell) for alpha in mis(sd, i): diff --git a/test/FIAT/unit/test_polynomial.py b/test/FIAT/unit/test_polynomial.py index c211fb815..efaf50feb 100644 --- a/test/FIAT/unit/test_polynomial.py +++ b/test/FIAT/unit/test_polynomial.py @@ -84,6 +84,31 @@ def eval_basis(f, pt): assert numpy.allclose(uh, exact, atol=1E-14) +@pytest.mark.parametrize("dim", [2, 3]) +@pytest.mark.parametrize("variant", [None, "bubble"]) +def test_high_order_expansion_derivatives(dim, variant): + cell = reference_element.default_simplex(dim) + degree = 5 + order = 4 + points = reference_element.make_lattice(cell.get_vertices(), 5, interior=1) + + fallback = expansions.ExpansionSet(cell, variant=variant) + fallback.recurrence_order = 2 + expected = fallback._tabulate(degree, points, order=order) + + recurrence = expansions.ExpansionSet(cell, variant=variant) + + def get_dmats(*args, **kwargs): + raise AssertionError("high-order derivatives should use recurrence tabulation") + + recurrence.get_dmats = get_dmats + actual = recurrence._tabulate(degree, points, order=order) + + assert actual.keys() == expected.keys() + for alpha in actual: + assert numpy.allclose(actual[alpha], expected[alpha], atol=1E-10, rtol=1E-10) + + @pytest.mark.parametrize("degree", [10]) def test_expansion_orthonormality(cell, degree): U = expansions.ExpansionSet(cell) From db31c4b80082517203f6a5b428743f6452264d35 Mon Sep 17 00:00:00 2001 From: Pablo Brubeck Date: Fri, 10 Jul 2026 23:27:02 +0100 Subject: [PATCH 02/34] Stabilize high-order HCT supersmooth constraints --- FIAT/macro.py | 15 +++++++++++++-- test/FIAT/unit/test_hct.py | 6 ++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/FIAT/macro.py b/FIAT/macro.py index 2f2795b15..d604a87e3 100644 --- a/FIAT/macro.py +++ b/FIAT/macro.py @@ -484,20 +484,31 @@ def __init__(self, ref_el, degree, order=1, vorder=None, shape=(), **kwargs): # Impose C^vorder super-smoothness at interior vertices # C^forder automatically gives C^{forder+dim-1} at the interior vertex verts = numpy.asarray(ref_el.get_vertices()) + has_vertex_constraints = False for vorder in set(order[0].values()): vids = [i for i in order[0] if order[0][i] == vorder] facets = chain.from_iterable(ref_el.connectivity[(0, sd-1)][v] for v in vids) forder = min(order[sd-1][f] for f in facets) sorder = forder + sd - 1 if vorder > sorder: + has_vertex_constraints = True jumps = expansion_set.tabulate_jumps(degree, verts[vids], order=vorder) rows.extend(numpy.vstack(jumps[r].T) for r in range(sorder+1, vorder+1)) if len(rows) > 0: for row in rows: row *= 1 / max(numpy.max(abs(row)), 1) - dual_mat = numpy.vstack(rows) - coeffs = polynomial_set.spanning_basis(dual_mat, nullspace=True) + if has_vertex_constraints: + # Project each constraint block onto the current nullspace so + # high-order vertex constraints are not buried in one large SVD. + coeffs = numpy.eye(expansion_set.get_num_members(degree)) + for row in rows: + restricted_row = numpy.dot(row, coeffs.T) + nsp = polynomial_set.spanning_basis(restricted_row, nullspace=True, rtol=1e-12) + coeffs = numpy.dot(nsp, coeffs) + else: + dual_mat = numpy.vstack(rows) + coeffs = polynomial_set.spanning_basis(dual_mat, nullspace=True) else: coeffs = numpy.eye(expansion_set.get_num_members(degree)) diff --git a/test/FIAT/unit/test_hct.py b/test/FIAT/unit/test_hct.py index 84e0a2379..c74d29348 100644 --- a/test/FIAT/unit/test_hct.py +++ b/test/FIAT/unit/test_hct.py @@ -30,6 +30,12 @@ def make_points(K, degree): return pts +@pytest.mark.parametrize("degree, space_dimension", [(13, 127), (14, 144)]) +def test_hct_high_order_degree(degree: int, space_dimension: int) -> None: + fe = HCT(ufc_simplex(2), degree) + assert fe.space_dimension() == space_dimension + + @pytest.mark.parametrize("reduced", (False, True)) def test_hct_constant(cell, reduced): # Test that bfs associated with point evaluation sum up to 1 From 8b2006ebb2260cd02a283a34db0ed528a8b2312c Mon Sep 17 00:00:00 2001 From: Pablo Brubeck Date: Mon, 13 Jul 2026 10:34:26 +0100 Subject: [PATCH 03/34] Automate zany basis transformations, starting with Morley Add finat/zany.py, a prototype that derives the basis transformation V = E V^c D of Kirby (2017) directly from a FIAT element's dual basis: - Sparsity comes from entity_dofs() and functional types: push-forward invariant nodes (point evaluations, integral averages) get identity rows. - The chain-rule factor V^c for facet normal-derivative moments reduces to Gram algebra, a = detJ sqrt(det Ghat / det G), b = G^{-1} T^T J n, in the frame of the physical normal and mapped reference tangents, valid in any dimension with no orientation logic. - The interpolation factor D is computed numerically by dual evaluation of tangential completion functionals (built from the normal node's own quadrature rule) against the nodal basis, replacing hand-derived univariate exactness rules. - The conditioning h-scaling generalizes via each node's max_deriv_order. The automated Morley transformation matches the hand-coded element to machine precision in 2D and 3D with a single dimension-independent code path, and passes check_zany_mapping. Move check_zany_mapping/make_unisolvent_points into the finat conftest and provide the checker as a fixture, since --import-mode=importlib forbids imports between test modules. Also add AGENTS.md notes on the transformation theory and the implementation conventions. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 279 +++++++++++++++++++++++++++++ finat/zany.py | 265 +++++++++++++++++++++++++++ test/finat/conftest.py | 105 ++++++++++- test/finat/test_zany_automation.py | 34 ++++ test/finat/test_zany_mapping.py | 121 ++----------- 5 files changed, 692 insertions(+), 112 deletions(-) create mode 100644 AGENTS.md create mode 100644 finat/zany.py create mode 100644 test/finat/test_zany_automation.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..b223cae3e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,279 @@ +# AGENTS.md for FIAT + +This document outlines the guidelines and architectural context for AI agents assisting with the FIAT codebase, functioning as a core component within the broader Firedrake ecosystem. + +--- + +## AI Contribution Policy + +When assisting with contributions to FIAT and the Firedrake project, AI agents and their human counterparts must adhere to the following strict policies: + +* The use of AI tools must be explicitly declared alongside the specific tool used. +* A human developer must lead the Pull Request. +* The human contributor must understand every change made to the codebase. +* Reviewer questions must be answered directly by the human, rather than acting as a relay to the AI. +* Any generated code must be executed locally to verify that it functions correctly. +* AI tools must not be used to resolve issues that are labeled as 'good first issue'. + +--- + +## FIAT's Role in the Architecture + +FIAT operates within Firedrake's automated system for solving partial differential equations via the finite element method. Its specific architectural responsibilities include: + +* FIAT provides compile-time pre-tabulated basis functions. +* These basis functions are utilized when the Two-Stage Form Compiler (TSFC) lowers Unified Form Language (UFL) into the GEM tensor language. +* The resulting GEM expressions represent mathematical operations over quadrature points. +* Mesh-topology bookkeeping routines within the ecosystem rely on FIAT ordering, such as the `create_cell_closure()` loop which builds a FIAT-ordered closure map necessary for subsequent code generation. + +--- + +## Repository Layout + +Despite being called "fiat", this repository (Python package `firedrake_fiat`) contains **three** packages plus their tests, all in one tree: + +* `FIAT/` — reference-element definitions: cells, polynomial sets, dual bases (`FIAT/functional.py` holds the taxonomy of degree-of-freedom functionals), and element families. +* `finat/` — symbolic layer on top of FIAT. Physically mapped elements (`finat/physically_mapped.py`, `finat/argyris.py`, `finat/morley.py`, `finat/hct.py`, `finat/piola_mapped.py`, …) construct GEM expressions for basis transformations. +* `gem/` — the tensor-algebra intermediate language used to express transformations symbolically (`gem/gem.py` for nodes, `gem/interpreter.py` to evaluate expressions numerically in tests). +* `test/` — note the singular name: tests live at `test/finat/test_zany_mapping.py`, `test/FIAT/...`, `test/gem/...` (not `tests/`). +* `literature/` — untracked folder with LaTeX sources of the two theory papers: `Kirby2017Transformation/paper.tex` (A general approach to transforming finite elements) and `BrubeckKirby2025Macroelements/paper.tex` (transformation theory for macroelements, §"Transformation theory"). + +## Environment and Setup + +Bugs can exist within Firedrake or any of its component packages, explicitly including FIAT. To effectively develop and debug FIAT: + +* Developers should use editable installs for subpackages like FIAT so that source code edits take effect without requiring a full reinstallation. +* The active branch or commit of each component must be verified before assuming a bug originates in the top-level Firedrake package. + +--- + +## Core Coding Rules + +Agents modifying FIAT code must follow these fundamental development principles: + +* Bug fixes must target the underlying mathematical or architectural root cause. +* Developers must avoid merely patching specific failing test cases or edge cases. +* Code complexity should be minimized by favoring the mathematical generality of finite elements over complicated special-case logic. +* Memorized API shapes must not be trusted. +* APIs across the ecosystem evolve, meaning properties can become methods, arguments can be renamed, and signatures can be deprecated. +* Agents must verify current API signatures by reading the installed source code instead of relying on outdated training data. +* Code documentation and comments must explain the present, correct code. +* Comments must not detail what a removed or incorrect approach previously did. + +--- + +## Pattern Matching and Mathematical Reasoning + +When designing or debugging FIAT, FInAT, and GEM changes, use the existing codebase as a library of +mathematical patterns rather than starting from ad hoc special cases: + +* Match new element constructions against the nearest existing family with the same structural + decomposition. Tensor-product, restricted, physically mapped, and enriched elements usually share + a factorization pattern that should be reused explicitly. +* When a feature seems to require a special case, test whether the same mathematics already appears in + another element family or mapping path. The right answer is often a more general basis + transformation, not a new branch. +* Separate reference-space reasoning from physical-space reasoning. In FIAT and FInAT, derive basis + transformations from the element map and continuity requirements first, then encode that structure + in GEM expressions. +* Treat tensor-product spaces as tensor-product mathematics. Look for Kronecker-style factorization + in basis matrices, coordinate mappings, and dual evaluations before introducing custom assembly + logic. +* For extruded or vertically constant factors, identify the dimension that is geometrically active + and the dimension that is algebraically passive. The passive factor should usually contribute a + simple constant, identity, or lower-dimensional pullback rather than a new geometric rule. +* Debug by matching the failing object against a known neighboring case: compare the cell, element + family, mapping type, continuity class, and tensor structure before changing code. +* In GEM, inspect whether an expression should factor, broadcast, or propagate a coordinate mapping. + If the expression is not matching the expected shape, the bug is often in the way indices or + subexpressions are assembled, not in the downstream optimizer. +* Use the mathematical continuity target as a design constraint. For finite elements, ask what + inter-element continuity the space must satisfy, then derive the local basis and transformation + rules from that requirement. +* Prefer proofs by structure over proofs by example. A construction is correct when the pullback, + restriction, and tensor-product algebra agree with the element's continuity and approximation + properties, not when one or two test cases happen to pass. + +## Transformation Theory (Kirby 2017; Brubeck & Kirby 2025) + +The mathematical framework for mapping non-affinely-equivalent elements. Notation: the +geometric map goes **from physical to reference**, $F: K \to \hat{K}$; pullback +$F^*(\hat{f}) = \hat{f}\circ F$; push-forward of a node $F_*(n) = n \circ F^*$. + +* Goal: the matrix $M$ with $\Psi = M\, F^*(\hat\Psi)$, expressing physical nodal basis + functions as combinations of pulled-back reference basis functions. +* Duality is the whole game: with $B_{ij} = F_*(n_i)(\hat\psi_j) = n_i(F^*(\hat\psi_j))$, + the Kronecker property gives $M = B^{-T}$ and $V = B^{-1}$, where $V$ relates nodes: + $\hat{N} = V\, F_*(N)$ (restricted to $P$). Hence **$M = V^T$**, and $V$ is what one + actually constructs, because push-forwards of nodes are computable by the chain rule. +* Affine equivalence (Lagrange): $F_*(N) = \hat{N}$, so $M = I$. +* Affine-interpolation equivalence (Hermite): spans of nodes at each point are preserved, + so $V$ is block diagonal, one small block per point-node group (e.g. $J^{-T}$ blocks for + vertex gradients — in the *paper's* $J$; see the FInAT convention below). +* Neither (Morley, Argyris, HCT): edge normal derivatives alone do not push forward into + the span of reference nodes. Fix by a **compatible nodal completion** $N^c \supset N$, + $\hat{N}^c \supset \hat{N}$ with $\mathrm{span}(F_*(N^c)) = \mathrm{span}(\hat{N}^c)$ + (add the tangential-derivative partners so each edge carries a full gradient). Then + $$V = E\, V^c\, D,$$ + with the three factors having distinct mathematical roles: + * $D \in \mathbb{R}^{\mu\times\nu}$: expresses the completed physical nodes in terms of + the actual physical nodes, *restricted to* $P$ ($\pi N^c = D\, \pi N$). Rows for nodes + already in $N$ are Boolean. Rows for completion nodes come from a univariate exactness + argument on the edge: a tangential-derivative quantity of a polynomial of known edge + degree is an exact linear combination of the endpoint values/derivatives (FTC for HCT's + $\mu^t_e$; the quintic endpoint rule for Argyris/Bell midpoint tangential derivatives). + This is the only factor that uses the polynomial degree of the space. + * $V^c \in \mathbb{R}^{\mu\times\mu}$: block diagonal, pure chain rule, + $\hat{N}^c = V^c F_*(N^c)$. Vertex value $\to 1$; vertex gradient $\to$ Jacobian block; + vertex Hessian $\to$ symmetric-square block $\Theta$; edge normal/tangential pair $\to$ + the $2\times2$ block $B_i = \hat{G}_i J^{-T} G_i^T$ (paper's $J$), where + $G = [\mathbf{n}\; \mathbf{t}]^T$. Only the first row of $B_i$ is needed + ($B_{nn}$, $B_{nt}$) since tangential rows are discarded by $E$. + * $E \in \mathbb{R}^{\nu\times\mu}$: Boolean extraction of $\hat{N}$ from $\hat{N}^c$. +* Constrained spaces, $F^*(\hat{P}) \neq P$ (Bell, reduced HCT): the space is + $P = \bigcap_i \mathrm{null}(\lambda_i)$ inside a larger $\tilde{P}$ that *is* preserved + (e.g. reduced HCT = HCT functions whose edge normal derivative is linear; + $\lambda_i$ = moment of the normal derivative against the quadratic Legendre polynomial + on edge $i$). Build the **extended element** $(K, \tilde{P}, [N; L])$ with the + constraints $\lambda_i$ appended as extra nodes; it is a valid finite element, and its + first $\nu$ nodal basis functions are exactly the nodal basis of the constrained + element. Transform the extended element with the $E V^c D$ machinery (completing each + $\lambda_i$ with its tangential partner $\lambda_i'$, again eliminated through an edge + exactness rule), then discard the constraint rows. +* Brubeck & Kirby 2025 refinements: + * Define *reference* edge nodes as integral **averages** ($1/|\hat{e}|$ scaling) while + physical nodes are plain moments: the edge-length Jacobian of the line integral is then + absorbed and no reference-edge-length bookkeeping or orientation logic is needed. + * High-order HCT/Argyris: edge normal-derivative moments are taken against Jacobi + polynomials $P_i^{(1,1)}$ (weights $(2,2)$ for Argyris, matching the vertex jet order), + so the edge-based functions are hierarchical. The tangential completion + $\mu^t_{e,i}$ integrates by parts against the *trace* moments (defined against + $\frac{d}{ds}P_i^{(1,1)}$): $\mu^t_{e,i}(f) = -\mu_{e,i}(f) + P_i^{(1,1)}(1)\, + \delta_{v_b}(f) - P_i^{(1,1)}(-1)\,\delta_{v_a}(f)$. This couples normal moments to + trace moments, not just vertex dofs. + +### Active work: automating the transformation (see `plan_zany_auto.md`) + +Goal: replace the hand-coded `basis_transformation` methods with a helper that derives +$V = E V^c D$ directly from a FIAT element's dual basis. The implementation must mirror +the theory factor by factor, not merely reproduce matrix entries: + +**Status (2026-07-13).** Prototype in `finat/zany.py` +(`zany_basis_transformation`); Morley works in 2D *and* 3D with one +dimension-independent code path, matching the hand-coded element to machine precision +(including the $h$-scaling) and passing `check_zany_mapping`. Tests: +`test/finat/test_zany_automation.py`; `check_zany_mapping` moved to the finat conftest +and is now provided to test modules as a pytest fixture (pytest runs with +`--import-mode=importlib`, so test modules cannot import from each other or from +conftest). + +Key derivations that made the automation work (record of pattern-matching strategy): + +* **Mapped-tangent completion makes $D$ purely numeric.** Choose the completion + functionals to be derivative moments along the *push-forwards of the scaled reference + tangents* $J\hat{t}_k$ (not unit physical tangents). Then the physical completion + functional pulls back *exactly* to a reference functional, so its expansion + coefficients in the element's nodes are reference-cell constants, computed by one + generalized Vandermonde (dual evaluation) — this subsumes every univariate closed-form + rule in the papers (FTC, quintic endpoint rule, integration by parts). The + hand-written code confirms this reading: in `_edge_transform`, numeric factors like + $-7/16$ multiply chain-rule factors `Bnt * Jt[i]`. +* **Frame decomposition by Gram algebra, no orientation logic.** The pullback of a + reference facet-normal derivative expands as $J\hat{n} = a\,n_{\text{phys}} + \sum_k + b_k\, J\hat{t}_k$. Because $n_{\text{phys}} \perp J\hat{t}_k$ and FIAT normals are + "UFC consistent" (the *same* tangent-based formula on reference and physical cells — + `UFCTriangle.compute_normal` is a rotation of the scaled edge tangent; + `UFCTetrahedron.compute_normal` is $-2\times$ the unit cross product of scaled face + tangents), all signs and normal-magnitude conventions cancel identically: + $$a = \det J\, \sqrt{\det\hat{G}/\det G}, \qquad b = G^{-1} T^T J\hat{n},$$ + with $T = [J\hat{t}_k]$, $G = T^T T$ (GEM), $\hat{G}$ the reference tangent Gram + (numeric). This is `_normal_tangential_transform` and `morley_transform` + generalized and unified across dimensions. Assumes $\det J > 0$. +* **Integral averages are push-forward invariant.** FIAT's Morley dofs are averages + (`FacetQuadratureRule(..., avg=True)` inside `IntegralMomentOfNormalDerivative`, and + explicit `avg=True` codim-2 moments), so value-moment nodes need identity rows and the + facet-measure Jacobians cancel from $a$ — no `physical_edge_lengths` needed anywhere. +* **Completion functionals are built from the node's own quadrature.** + `IntegralMomentOfDerivative(ref_el, node.Q, node.f_at_qpts, t)` reuses the stored rule + and weight of the normal node, guaranteeing the tangential partners carry identical + scaling conventions. +* **The conditioning $h$-scaling generalizes via `max_deriv_order`.** Column $j$ is + scaled by $h_E^{-m}$ where $m$ is the derivative order of node $j$ and $h_E$ averages + `cell_size()` over the vertices of its entity. This reproduces the per-element + hand-written scalings (Morley facet dofs, Hermite/Argyris vertex jets). Note + `cell_size()` returns raw numpy values in the test mappings but GEM in Firedrake, so + use operator arithmetic (`havg**(-m)`), not explicit GEM node constructors. + +Next steps: vertex-jet groups (`PointDerivative`/`PointSecondDerivative` etc. → +`_jet_transform` blocks) to cover Hermite, then completion rows that couple to +non-invariant (jet) dofs via recursive substitution to cover HCT/Argyris, then the +extended-element path for reduced HCT/Bell. + +The implementation mirrors the theory factor by factor: + +1. **Sparsity from topology**: each row of $V$ is a reference node; its nonzero columns + are the physical nodes on the same entity (via $V^c$) plus the nodes on adjacent + entities pulled in by $D$ (e.g. edge rows couple to the edge's vertex dofs, and for + hierarchical elements to the same edge's trace moments). Derive this from + `entity_dofs()` and functional types (`FIAT/functional.py`), never hard-code indices. +2. **Completion from functional type**: a normal-derivative node's completion partner is + the corresponding tangential-derivative node; the completion is a per-entity statement, + determined by which components of the derivative jet the dual basis lacks. +3. **$D$ from unisolvence, not from closed-form rules**: the univariate exactness rules + (FTC, quintic endpoint rule, integration by parts against trace moments) are all + instances of one computation — express a completion functional applied to the + polynomial space in the basis of the element's own nodes, i.e. solve with the + generalized Vandermonde matrix. This is where GEM-based dual evaluation comes in. +4. Constrained spaces (reduced HCT, Bell) reuse the same machinery on the extended + element; the FIAT element must expose the constraint functionals as extra dofs. + +### FInAT implementation conventions + +* `coordinate_mapping.jacobian_at(point)` returns $\partial x_{\text{phys}} / \partial + x_{\text{ref}}$ — the **inverse** of the papers' $J$ (papers map physical → reference). + So where the paper writes $J^{-T}$, the code uses the FInAT Jacobian transposed; e.g. + Hermite's vertex blocks are `J[j, k]` entries of $M$ directly. +* `basis_transformation` builds the numpy object array `V` (row = reference node, column + = physical node, entries are GEM scalars) and returns `gem.ListTensor(V.T)` = $M$. + Start from `identity(ndof)` (in `finat/physically_mapped.py`) and overwrite the + non-identity rows. +* Existing generalized helpers, all in `finat/argyris.py`: + * `_jet_transform(J, order)`: chain-rule block for a symmetric derivative jet of any + order (order 1 → Jacobian, order 2 → $\Theta$, …), handling symmetric-component + flattening. + * `_vertex_transform(V, vorder, cell, mapping)`: places jet blocks for every vertex. + * `_normal_tangential_transform(cell, J, detJ, edge)`: returns $(B_{nn}, B_{nt}, Jt)$ + for an edge, expressing $B$ entries via $G^{TT}$ Gram data so only GEM-representable + quantities appear (`detJ/beta`, `alpha/beta`); 3D variant for faces is + `morley_transform` in `finat/morley.py`. + * `_edge_transform(V, vorder, eorder, cell, mapping, avg)`: the Jacobi-moment edge rows + for integral-variant Argyris/HCT, encoding the endpoint values + $P_i^{(1,1)}(\pm 1)$ and the trace-moment coupling. +* Reduced/constrained elements (`ReducedHsiehCloughTocher` in `finat/hct.py`, `Bell`): + the FIAT element is the *extended* element (12 basis functions for reduced HCT), the + FInAT element exposes only $\nu$ dofs: `V = identity(numbf, ndof)` is rectangular, + `entity_dofs()` is overridden to empty the constrained entities, and + `space_dimension()` returns the reduced count. +* Conditioning convention: after assembling `V`, columns associated with derivative dofs + are rescaled by powers of `coordinate_mapping.cell_size()` (a per-vertex $h$). This + redefines the physical dofs as $h$-scaled derivatives — consistent across cells because + the scaling depends only on shared vertices/edges. Any new transformation must follow + the same convention or mass-matrix conditioning degrades + (`test/finat/test_mass_conditioning.py`). +* Verification: `test/finat/test_zany_mapping.py::check_zany_mapping` computes the exact + $M$ numerically by tabulating the FIAT element on a *physical* cell and least-squares + solving against pulled-back (and Piola-mapped, if applicable) reference tabulations, + then compares against `basis_transformation` evaluated through `gem.interpreter`. Its + assertion message pretty-prints the relative-error matrix with row/column indices — + read those indices to identify which dof couplings are wrong. + +## Style and Conventions + +When writing Python code for FIAT, maintain the ecosystem's structural and stylistic integrity: + +* Class attributes must be declared in one visible location. +* Attributes must be initialized in the `__init__` constructor or declared as a `functools.cached_property` if they are expensive to compute. +* Ad hoc lazy initialization discovering attributes via `hasattr`, `setattr`, or `getattr` scattered across methods is strictly prohibited. +* Boolean attributes must be used to record initialization intent and state instead of probing for the presence of state built by an initialization function. +* New code must include type hints on all function and method signatures. +* Public-facing APIs must include properly formatted `numpydoc`-style docstrings. diff --git a/finat/zany.py b/finat/zany.py new file mode 100644 index 000000000..2fb030283 --- /dev/null +++ b/finat/zany.py @@ -0,0 +1,265 @@ +"""Automatic basis transformations for physically mapped elements. + +This module automates the transformation theory of Kirby (2017) and +Brubeck & Kirby (2025). Given a FIAT element whose degrees of freedom +are not preserved under push-forward, it constructs the matrix +:math:`V` relating the reference nodes to the push-forwards of the +physical nodes, so that the physical basis functions are obtained as +:math:`M F^*(\\hat\\Psi)` with :math:`M = V^T`. + +The construction follows the factorization :math:`V = E V^c D`: + +* each reference node is pulled back to the physical cell and expanded + by the chain rule in a frame adapted to its entity: the physical + direction appearing in the corresponding physical node (e.g. the + physical facet normal) completed with the push-forwards of the + reference tangents (the :math:`V^c` factor and the extraction + :math:`E`); +* the completion functionals are derivatives along *mapped* reference + tangents, so they coincide with reference functionals whose expansion + in the element's own nodes is a purely numeric generalized Vandermonde + row (the :math:`D` factor), computed by dual evaluation instead of + hand-derived univariate exactness rules. +""" + +from functools import reduce +from operator import add + +import numpy + +from FIAT.finite_element import FiniteElement +from FIAT.functional import (Functional, IntegralMoment, + IntegralMomentOfDerivative, + IntegralMomentOfNormalDerivative, + PointEvaluation) +from gem import Literal, ListTensor, Node, Power +from finat.physically_mapped import (PhysicalGeometry, adjugate, + determinant, identity) + + +def dual_evaluation_matrix(fiat_element: FiniteElement, + functionals: list[Functional]) -> numpy.ndarray: + """Evaluate functionals against the nodal basis of a FIAT element. + + This is the generalized Vandermonde computation providing the + numeric coefficients of the :math:`D` factor: the restriction of a + functional :math:`\\ell` to the polynomial space :math:`P` satisfies + :math:`\\pi \\ell = \\sum_j \\ell(\\psi_j)\\, \\pi n_j`. + + Parameters + ---------- + fiat_element : + The FIAT element providing the nodal basis. + functionals : + Functionals to evaluate. + + Returns + ------- + numpy.ndarray + Matrix with entry (k, j) equal to the k-th functional applied + to the j-th nodal basis function. + """ + poly_set = fiat_element.get_nodal_basis() + coeffs = poly_set.get_coeffs() + riesz = numpy.array([f.to_riesz(poly_set).flatten() for f in functionals]) + return riesz @ coeffs.reshape(coeffs.shape[0], -1).T + + +def is_invariant(node: Functional) -> bool: + """Return whether a functional is preserved under push-forward. + + Point evaluations and integral moments of the function value against + an intrinsically defined weight (constructed from the same + reference-facet quadrature rule on both cells) satisfy + :math:`F_*(n) = \\hat{n}`, so their rows of :math:`V` are identity. + """ + return type(node) in {PointEvaluation, IntegralMoment} + + +def _normal_tangential_frame(fiat_element: FiniteElement, entity: int, + J: Node, detJ: Node) -> tuple: + """Chain-rule data for the facet normal/tangential frame. + + The pullback of the reference normal-derivative direction expands in + the frame of the physical facet normal and the mapped reference + tangents, + + .. math:: J\\hat{n} = a\\, n + \\sum_k b_k\\, J\\hat{t}_k. + + Orthogonality of the physical normal to the mapped tangents and the + identical (tangent-based) normal convention on both cells reduce the + coefficients to Gram-matrix algebra: + :math:`a = \\det J \\sqrt{\\det\\hat{G}/\\det G}` and + :math:`b = G^{-1} T^T J\\hat{n}` with :math:`T = [J\\hat{t}_k]` and + Gram matrices :math:`G = T^T T`, :math:`\\hat{G}_{kl} = \\hat{t}_k + \\cdot \\hat{t}_l`. + + Parameters + ---------- + fiat_element : + The FIAT element, providing the reference cell. + entity : + The facet number. + J : + GEM expression for the cell Jacobian. + detJ : + GEM expression for the Jacobian determinant. + + Returns + ------- + tuple + ``(a, b)`` with ``a`` the GEM coefficient of the physical + normal node and ``b`` the list of GEM coefficients of the + mapped tangential functionals. + """ + ref_el = fiat_element.get_reference_element() + sd = ref_el.get_spatial_dimension() + that = ref_el.compute_tangents(sd - 1, entity) + nhat = ref_el.compute_normal(entity) + + Jn = J @ Literal(nhat) + Jt = [J @ Literal(t) for t in that] + G = numpy.array([[Jt[k] @ Jt[l] for l in range(sd - 1)] + for k in range(sd - 1)], dtype=object) + detG = determinant(G) + adjG = adjugate(G) + Tn = [Jt[k] @ Jn for k in range(sd - 1)] + b = [reduce(add, (adjG[k, l] * Tn[l] for l in range(sd - 1))) / detG + for k in range(sd - 1)] + + Ghat = numpy.dot(that, that.T) + a = detJ * Literal(numpy.linalg.det(Ghat) ** 0.5) / Power(detG, Literal(0.5)) + return a, b + + +def _facet_normal_moment_rows(V: numpy.ndarray, dofs: list, + fiat_element: FiniteElement, entity: int, + J: Node, detJ: Node, invariant: set, + tol: float) -> None: + """Fill the rows of V for normal-derivative moments on a facet. + + Parameters + ---------- + V : + Object array being assembled, with entry (i, j) relating + reference node i to the push-forward of physical node j. + dofs : + Indices of the normal-derivative moment nodes on this facet. + fiat_element : + The FIAT element. + entity : + The facet number. + J, detJ : + GEM expressions for the cell Jacobian and its determinant. + invariant : + Indices of the push-forward invariant nodes. + tol : + Tolerance for detecting zeros in the numeric completion + coefficients. + """ + ref_el = fiat_element.get_reference_element() + sd = ref_el.get_spatial_dimension() + that = ref_el.compute_tangents(sd - 1, entity) + nodes = fiat_element.dual_basis() + + a, b = _normal_tangential_frame(fiat_element, entity, J, detJ) + + # Nodal completion: same integral moment rule, tangential directions. + completion = [IntegralMomentOfDerivative(ref_el, nodes[i].Q, + nodes[i].f_at_qpts, t) + for i in dofs for t in that] + C = dual_evaluation_matrix(fiat_element, completion) + C[abs(C) < tol] = 0 + + for row, (i, bk) in zip(C, ((i, bk) for i in dofs for bk in b)): + V[i, i] = a + for j in numpy.flatnonzero(row): + if j not in invariant: + raise NotImplementedError( + f"Completion of node {i} couples to node {j} of type " + f"{type(nodes[j]).__name__}, which is not yet handled.") + V[i, j] = V[i, j] + Literal(row[j]) * bk + + +def _conditioning_scaling(V: numpy.ndarray, fiat_element: FiniteElement, + coordinate_mapping: PhysicalGeometry) -> None: + """Rescale derivative degrees of freedom by the cell size. + + Each physical node of derivative order :math:`m` is redefined with a + factor :math:`h^{-m}`, where :math:`h` averages the cell size over + the vertices of its entity. This is the FInAT convention keeping + the mass matrix well-conditioned; it is consistent across cells + because the scaling only depends on shared entities. + + Parameters + ---------- + V : + Object array being assembled; columns are rescaled in place. + fiat_element : + The FIAT element. + coordinate_mapping : + Object providing the physical geometry as GEM expressions. + """ + # cell_size may be a GEM expression or a numpy array of numbers + h = coordinate_mapping.cell_size() + top = fiat_element.get_reference_element().get_topology() + nodes = fiat_element.dual_basis() + entity_ids = fiat_element.entity_dofs() + for dim in entity_ids: + for entity in entity_ids[dim]: + verts = top[dim][entity] + havg = reduce(add, (h[v] for v in verts)) / len(verts) + for i in entity_ids[dim][entity]: + order = nodes[i].max_deriv_order + if order > 0: + V[:, i] = V[:, i] * havg**(-order) + + +def zany_basis_transformation(fiat_element: FiniteElement, + coordinate_mapping: PhysicalGeometry, + tol: float = 1e-12) -> ListTensor: + """Compute the basis transformation matrix of a FIAT element. + + Parameters + ---------- + fiat_element : + The FIAT element defined on the reference cell. + coordinate_mapping : + Object providing the physical geometry as GEM expressions. + tol : + Tolerance for detecting zeros in the numeric completion + coefficients. + + Returns + ------- + gem.ListTensor + The transformation :math:`M = V^T` mapping pulled-back reference + basis functions to physical nodal basis functions. + """ + ref_el = fiat_element.get_reference_element() + sd = ref_el.get_spatial_dimension() + bary, = ref_el.make_points(sd, 0, sd + 1) + J = coordinate_mapping.jacobian_at(bary) + detJ = coordinate_mapping.detJ_at(bary) + + nodes = fiat_element.dual_basis() + invariant = {i for i, node in enumerate(nodes) if is_invariant(node)} + + V = identity(fiat_element.space_dimension()) + entity_ids = fiat_element.entity_dofs() + for dim in entity_ids: + for entity in entity_ids[dim]: + dofs = [i for i in entity_ids[dim][entity] if i not in invariant] + if not dofs: + continue + if all(isinstance(nodes[i], IntegralMomentOfNormalDerivative) + for i in dofs): + _facet_normal_moment_rows(V, dofs, fiat_element, entity, + J, detJ, invariant, tol) + else: + unhandled = {type(nodes[i]).__name__ for i in dofs} + raise NotImplementedError( + f"Cannot yet transform nodes of type {unhandled}.") + + _conditioning_scaling(V, fiat_element, coordinate_mapping) + return ListTensor(V.T) diff --git a/test/finat/conftest.py b/test/finat/conftest.py index c31ef7350..a3288190a 100644 --- a/test/finat/conftest.py +++ b/test/finat/conftest.py @@ -1,8 +1,11 @@ +import pprint + import pytest import FIAT import gem import numpy as np -from finat.physically_mapped import PhysicalGeometry +from gem.interpreter import evaluate +from finat.physically_mapped import PhysicalGeometry, PhysicallyMappedElement class MyMapping(PhysicalGeometry): @@ -126,3 +129,103 @@ def ref_to_phys(ref_el, phys_el): def scaled_ref_to_phys(ref_el): return {dim: [ScaledMapping(ref_el[dim], scaled_simplex(dim, 0.5**k)) for k in range(3)] for dim in ref_el} + + +def make_unisolvent_points(element, interior=False): + degree = element.degree() + ref_complex = element.get_reference_complex() + top = ref_complex.get_topology() + pts = [] + if interior: + dim = ref_complex.get_spatial_dimension() + for entity in top[dim]: + pts.extend(ref_complex.make_points(dim, entity, degree+dim+1, variant="gll")) + else: + for dim in top: + for entity in top[dim]: + pts.extend(ref_complex.make_points(dim, entity, degree, variant="gll")) + return pts + + +def check_zany_mapping(element, ref_to_phys, *args, **kwargs): + phys_cell = ref_to_phys.phys_cell + ref_cell = ref_to_phys.ref_cell + phys_element = element(phys_cell, *args, **kwargs).fiat_equivalent + finat_element = element(ref_cell, *args, **kwargs) + + ref_element = finat_element._element + ref_cell = ref_element.get_reference_element() + phys_cell = phys_element.get_reference_element() + sd = ref_cell.get_spatial_dimension() + + shape = ref_element.value_shape() + ref_pts = make_unisolvent_points(ref_element, interior=True) + ref_vals = ref_element.tabulate(0, ref_pts)[(0,)*sd] + + phys_pts = make_unisolvent_points(phys_element, interior=True) + phys_vals = phys_element.tabulate(0, phys_pts)[(0,)*sd] + + mapping = ref_element.mapping()[0] + if mapping == "affine": + ref_vals_piola = ref_vals + else: + # Piola map the reference elements + J, b = FIAT.reference_element.make_affine_mapping(ref_cell.vertices, + phys_cell.vertices) + K = [] + if "covariant" in mapping: + K.append(np.linalg.inv(J).T) + if "contravariant" in mapping: + K.append(J / np.linalg.det(J)) + + if len(shape) == 2: + piola_map = lambda x: K[0] @ x @ K[-1].T + else: + piola_map = lambda x: K[0] @ x + + ref_vals_piola = np.zeros(ref_vals.shape) + for i in range(ref_vals.shape[0]): + for k in range(ref_vals.shape[-1]): + ref_vals_piola[i, ..., k] = piola_map(ref_vals[i, ..., k]) + + # Zany map the results + num_bfs = phys_element.space_dimension() + num_dofs = finat_element.space_dimension() + if isinstance(finat_element, PhysicallyMappedElement): + Mgem = finat_element.basis_transformation(ref_to_phys) + M = evaluate([Mgem])[0].arr + ref_vals_zany = np.tensordot(M, ref_vals_piola, (-1, 0)) + else: + M = np.eye(num_dofs, num_bfs) + ref_vals_zany = ref_vals_piola + + # Solve for the basis transformation and compare results + Phi = ref_vals_piola.reshape(num_bfs, -1) + phi = phys_vals.reshape(num_bfs, -1) + Vh, residual, *_ = np.linalg.lstsq(Phi.T, phi.T) + Mh = Vh.T + Mh = Mh[:num_dofs] + tol = 1E-10 + Mh[abs(Mh) < tol] = 0 + M[abs(M) < tol] = 0 + + delta = M.T - Mh.T + delta[abs(delta) < tol] = 0 + with np.errstate(divide='ignore', invalid='ignore'): + error = delta / Mh.T + error[error != error] = 0 + error[delta == 0] = 0 + error[abs(error) < tol] = 0 + + inds = tuple(map(np.unique, np.nonzero(error))) + error = error[np.ix_(*inds)] + error[error != 0] += 1 + + pp = pprint.PrettyPrinter(width=140, compact=True) + assert np.allclose(residual, 0), pp.pformat((np.round(error, 8).tolist(), *inds)) + assert np.allclose(ref_vals_zany, phys_vals[:num_dofs]), pp.pformat((np.round(error, 8).tolist(), *inds)) + + +@pytest.fixture(name="check_zany_mapping") +def check_zany_mapping_fixture(): + return check_zany_mapping diff --git a/test/finat/test_zany_automation.py b/test/finat/test_zany_automation.py new file mode 100644 index 000000000..8a541356b --- /dev/null +++ b/test/finat/test_zany_automation.py @@ -0,0 +1,34 @@ +import finat +import numpy as np +import pytest + +from gem.interpreter import evaluate +from finat.zany import zany_basis_transformation + + +class AutoMorley(finat.Morley): + """Morley element with automatically derived basis transformation.""" + def basis_transformation(self, coordinate_mapping): + return zany_basis_transformation(self._element, coordinate_mapping) + + +auto_elements = { + AutoMorley: finat.Morley, +} + + +@pytest.mark.parametrize("element", auto_elements) +@pytest.mark.parametrize("dimension", [2, 3]) +def test_auto_transformation(check_zany_mapping, ref_to_phys, element, dimension): + check_zany_mapping(element, ref_to_phys[dimension]) + + +@pytest.mark.parametrize("element", auto_elements) +@pytest.mark.parametrize("dimension", [2, 3]) +def test_auto_matches_handcoded(scaled_ref_to_phys, element, dimension): + handcoded = auto_elements[element] + for mapping in scaled_ref_to_phys[dimension]: + cell = mapping.ref_cell + Ma = evaluate([element(cell).basis_transformation(mapping)])[0].arr + Mh = evaluate([handcoded(cell).basis_transformation(mapping)])[0].arr + assert np.allclose(Ma, Mh, atol=1e-14) diff --git a/test/finat/test_zany_mapping.py b/test/finat/test_zany_mapping.py index 9220dca99..95524b6a7 100644 --- a/test/finat/test_zany_mapping.py +++ b/test/finat/test_zany_mapping.py @@ -1,106 +1,5 @@ -import FIAT import finat -import numpy as np import pytest -import pprint - -from gem.interpreter import evaluate -from finat.physically_mapped import PhysicallyMappedElement - - -def make_unisolvent_points(element, interior=False): - degree = element.degree() - ref_complex = element.get_reference_complex() - top = ref_complex.get_topology() - pts = [] - if interior: - dim = ref_complex.get_spatial_dimension() - for entity in top[dim]: - pts.extend(ref_complex.make_points(dim, entity, degree+dim+1, variant="gll")) - else: - for dim in top: - for entity in top[dim]: - pts.extend(ref_complex.make_points(dim, entity, degree, variant="gll")) - return pts - - -def check_zany_mapping(element, ref_to_phys, *args, **kwargs): - phys_cell = ref_to_phys.phys_cell - ref_cell = ref_to_phys.ref_cell - phys_element = element(phys_cell, *args, **kwargs).fiat_equivalent - finat_element = element(ref_cell, *args, **kwargs) - - ref_element = finat_element._element - ref_cell = ref_element.get_reference_element() - phys_cell = phys_element.get_reference_element() - sd = ref_cell.get_spatial_dimension() - - shape = ref_element.value_shape() - ref_pts = make_unisolvent_points(ref_element, interior=True) - ref_vals = ref_element.tabulate(0, ref_pts)[(0,)*sd] - - phys_pts = make_unisolvent_points(phys_element, interior=True) - phys_vals = phys_element.tabulate(0, phys_pts)[(0,)*sd] - - mapping = ref_element.mapping()[0] - if mapping == "affine": - ref_vals_piola = ref_vals - else: - # Piola map the reference elements - J, b = FIAT.reference_element.make_affine_mapping(ref_cell.vertices, - phys_cell.vertices) - K = [] - if "covariant" in mapping: - K.append(np.linalg.inv(J).T) - if "contravariant" in mapping: - K.append(J / np.linalg.det(J)) - - if len(shape) == 2: - piola_map = lambda x: K[0] @ x @ K[-1].T - else: - piola_map = lambda x: K[0] @ x - - ref_vals_piola = np.zeros(ref_vals.shape) - for i in range(ref_vals.shape[0]): - for k in range(ref_vals.shape[-1]): - ref_vals_piola[i, ..., k] = piola_map(ref_vals[i, ..., k]) - - # Zany map the results - num_bfs = phys_element.space_dimension() - num_dofs = finat_element.space_dimension() - if isinstance(finat_element, PhysicallyMappedElement): - Mgem = finat_element.basis_transformation(ref_to_phys) - M = evaluate([Mgem])[0].arr - ref_vals_zany = np.tensordot(M, ref_vals_piola, (-1, 0)) - else: - M = np.eye(num_dofs, num_bfs) - ref_vals_zany = ref_vals_piola - - # Solve for the basis transformation and compare results - Phi = ref_vals_piola.reshape(num_bfs, -1) - phi = phys_vals.reshape(num_bfs, -1) - Vh, residual, *_ = np.linalg.lstsq(Phi.T, phi.T) - Mh = Vh.T - Mh = Mh[:num_dofs] - tol = 1E-10 - Mh[abs(Mh) < tol] = 0 - M[abs(M) < tol] = 0 - - delta = M.T - Mh.T - delta[abs(delta) < tol] = 0 - with np.errstate(divide='ignore', invalid='ignore'): - error = delta / Mh.T - error[error != error] = 0 - error[delta == 0] = 0 - error[abs(error) < tol] = 0 - - inds = tuple(map(np.unique, np.nonzero(error))) - error = error[np.ix_(*inds)] - error[error != 0] += 1 - - pp = pprint.PrettyPrinter(width=140, compact=True) - assert np.allclose(residual, 0), pp.pformat((np.round(error, 8).tolist(), *inds)) - assert np.allclose(ref_vals_zany, phys_vals[:num_dofs]), pp.pformat((np.round(error, 8).tolist(), *inds)) @pytest.mark.parametrize("element", [ @@ -110,7 +9,7 @@ def check_zany_mapping(element, ref_to_phys, *args, **kwargs): finat.WuXuH3NC, finat.WuXuRobustH3NC, ]) -def test_C1_triangle(ref_to_phys, element): +def test_C1_triangle(check_zany_mapping, ref_to_phys, element): check_zany_mapping(element, ref_to_phys[2]) @@ -118,7 +17,7 @@ def test_C1_triangle(ref_to_phys, element): finat.Morley, finat.Walkington, ]) -def test_C1_tetrahedron(ref_to_phys, element): +def test_C1_tetrahedron(check_zany_mapping, ref_to_phys, element): check_zany_mapping(element, ref_to_phys[3]) @@ -127,7 +26,7 @@ def test_C1_tetrahedron(ref_to_phys, element): finat.QuadraticPowellSabin12, finat.ReducedHsiehCloughTocher, ]) -def test_C1_macroelements(ref_to_phys, element): +def test_C1_macroelements(check_zany_mapping, ref_to_phys, element): kwargs = {} if element == finat.QuadraticPowellSabin12: kwargs = dict(avg=True) @@ -140,11 +39,11 @@ def test_C1_macroelements(ref_to_phys, element): *((finat.AlfeldC2, k) for k in range(5, 7)), *((finat.BrambleZlamalC2, k) for k in range(9, 11)), ]) -def test_high_order_Ck_elements(ref_to_phys, element, degree): +def test_high_order_Ck_elements(check_zany_mapping, ref_to_phys, element, degree): check_zany_mapping(element, ref_to_phys[2], degree, avg=True) -def test_argyris_point(ref_to_phys): +def test_argyris_point(check_zany_mapping, ref_to_phys): check_zany_mapping(finat.Argyris, ref_to_phys[2], variant="point") @@ -174,7 +73,7 @@ def test_argyris_point(ref_to_phys): *((2, e) for e in zany_piola_elements[3]), *((3, e) for e in zany_piola_elements[3]), ]) -def test_piola(ref_to_phys, element, dimension): +def test_piola(check_zany_mapping, ref_to_phys, element, dimension): check_zany_mapping(element, ref_to_phys[dimension]) @@ -182,14 +81,14 @@ def test_piola(ref_to_phys, element, dimension): (3, finat.MardalTaiWinther, 2), (3, finat.GuzmanNeilanFirstKindH1, 2), ]) -def test_high_order_stokes_elements(ref_to_phys, element, dimension, degree): +def test_high_order_stokes_elements(check_zany_mapping, ref_to_phys, element, dimension, degree): check_zany_mapping(element, ref_to_phys[dimension], degree) @pytest.mark.parametrize("element, degree, variant", [ *((finat.HuZhang, k, v) for v in ("integral", "point") for k in range(3, 6)), ]) -def test_piola_triangle_high_order(ref_to_phys, element, degree, variant): +def test_piola_triangle_high_order(check_zany_mapping, ref_to_phys, element, degree, variant): check_zany_mapping(element, ref_to_phys[2], degree, variant) @@ -201,7 +100,7 @@ def test_piola_triangle_high_order(ref_to_phys, element, degree, variant): ]) @pytest.mark.parametrize("dimension", [2, 3]) @pytest.mark.parametrize("variant", [None, "alfeld"]) -def test_affine(ref_to_phys, element, degree, variant, dimension): +def test_affine(check_zany_mapping, ref_to_phys, element, degree, variant, dimension): check_zany_mapping(element, ref_to_phys[dimension], degree, variant=variant) @@ -209,5 +108,5 @@ def test_affine(ref_to_phys, element, degree, variant, dimension): @pytest.mark.parametrize("degree", [1, 2]) @pytest.mark.parametrize("dimension", [2, 3]) @pytest.mark.parametrize("variant", [None, "iso"]) -def test_macro_piola(ref_to_phys, element, degree, variant, dimension): +def test_macro_piola(check_zany_mapping, ref_to_phys, element, degree, variant, dimension): check_zany_mapping(element, ref_to_phys[dimension], degree, variant=variant) From 78b9f0d36dbd2dd6f86476a87cf660e86e2653c7 Mon Sep 17 00:00:00 2001 From: Pablo Brubeck Date: Mon, 13 Jul 2026 10:57:22 +0100 Subject: [PATCH 04/34] Compute transformations generically via symbolic finat.Functional Replace the per-functional-kind row builders with a generic framework: - finat/functional.py: a degree of freedom is represented symbolically as l(f) = sum_q w_q , constructed from any FIAT functional's pt_dict/deriv_dict with the direction recovered numerically, so no dispatch over FIAT functional types is needed. Operations: covariant pullback (direction contracts with J), numeric dual evaluation against a nodal basis, and direction replacement. - finat/zany.py: FacetFrame holds the normal/tangential frame and a generic adjugate-based symbolic solve; one assembly loop treats every node identically. Push-forward invariance of value and tangential nodes is derived, not type-checked. Completion remainders recurse through already-assembled rows of V, ordered by entity dimension. - finat/morley.py: Morley is reimplemented on the framework; its basis transformation is one call to zany_basis_transformation. morley_transform moves verbatim to finat/walkington.py, its only remaining user. The remainder coefficients r_k = x_k - c beta_k are invariant under the sign ambiguity of the numerically recovered direction; the naive a*x_k formula flips sign exactly on the edges where the SVD picks the opposite orientation. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 109 ++++++----- finat/__init__.py | 1 + finat/functional.py | 177 +++++++++++++++++ finat/morley.py | 81 +------- finat/walkington.py | 26 ++- finat/zany.py | 305 ++++++++++++++--------------- test/finat/test_zany_automation.py | 69 ++++--- 7 files changed, 467 insertions(+), 301 deletions(-) create mode 100644 finat/functional.py diff --git a/AGENTS.md b/AGENTS.md index b223cae3e..2cdd9aecf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -158,56 +158,73 @@ Goal: replace the hand-coded `basis_transformation` methods with a helper that d $V = E V^c D$ directly from a FIAT element's dual basis. The implementation must mirror the theory factor by factor, not merely reproduce matrix entries: -**Status (2026-07-13).** Prototype in `finat/zany.py` -(`zany_basis_transformation`); Morley works in 2D *and* 3D with one -dimension-independent code path, matching the hand-coded element to machine precision -(including the $h$-scaling) and passing `check_zany_mapping`. Tests: -`test/finat/test_zany_automation.py`; `check_zany_mapping` moved to the finat conftest -and is now provided to test modules as a pytest fixture (pytest runs with +**Status (2026-07-13).** The framework lives in `finat/functional.py` (the symbolic +`finat.Functional`) and `finat/zany.py` (`FacetFrame`, `zany_basis_transformation`). +`finat.Morley` is reimplemented on it: `basis_transformation` is one call to +`zany_basis_transformation(self._element, coordinate_mapping)`, working in 2D and 3D +through a single dimension-independent code path, verified by `check_zany_mapping` +and matching the previous hand-coded matrices (including the $h$-scaling) to machine +precision before that code was deleted. `morley_transform` moved verbatim to +`finat/walkington.py`, its only remaining user. Tests: +`test/finat/test_zany_automation.py`; `check_zany_mapping` lives in the finat conftest +and is provided to test modules as a pytest fixture (pytest runs with `--import-mode=importlib`, so test modules cannot import from each other or from -conftest). - -Key derivations that made the automation work (record of pattern-matching strategy): - -* **Mapped-tangent completion makes $D$ purely numeric.** Choose the completion - functionals to be derivative moments along the *push-forwards of the scaled reference - tangents* $J\hat{t}_k$ (not unit physical tangents). Then the physical completion - functional pulls back *exactly* to a reference functional, so its expansion - coefficients in the element's nodes are reference-cell constants, computed by one - generalized Vandermonde (dual evaluation) — this subsumes every univariate closed-form - rule in the papers (FTC, quintic endpoint rule, integration by parts). The - hand-written code confirms this reading: in `_edge_transform`, numeric factors like - $-7/16$ multiply chain-rule factors `Bnt * Jt[i]`. -* **Frame decomposition by Gram algebra, no orientation logic.** The pullback of a - reference facet-normal derivative expands as $J\hat{n} = a\,n_{\text{phys}} + \sum_k - b_k\, J\hat{t}_k$. Because $n_{\text{phys}} \perp J\hat{t}_k$ and FIAT normals are - "UFC consistent" (the *same* tangent-based formula on reference and physical cells — - `UFCTriangle.compute_normal` is a rotation of the scaled edge tangent; - `UFCTetrahedron.compute_normal` is $-2\times$ the unit cross product of scaled face - tangents), all signs and normal-magnitude conventions cancel identically: - $$a = \det J\, \sqrt{\det\hat{G}/\det G}, \qquad b = G^{-1} T^T J\hat{n},$$ - with $T = [J\hat{t}_k]$, $G = T^T T$ (GEM), $\hat{G}$ the reference tangent Gram - (numeric). This is `_normal_tangential_transform` and `morley_transform` - generalized and unified across dimensions. Assumes $\det J > 0$. +conftest — recover classes from fixture instances via `type(...)` if needed). + +**Framework design.** A dof is a symbolic `finat.Functional`: +$\ell(f) = \sum_q w_q \langle D, \nabla^m f(x_q)\rangle$ with numeric points/weights +and a direction tensor $D$ that is numeric on the reference cell and GEM otherwise. +There is *no dispatch over FIAT functional types*: `Functional.from_fiat` reads only +`pt_dict`/`deriv_dict` and recovers the order and common direction numerically +(rank-one SVD of the derivative weights). Operations: covariant `pullback(J)` +(contract direction slots with $J$), `with_direction`, and numeric `evaluate` against +a nodal basis (the generalized Vandermonde realizing the $D$ factor of $V = E V^c D$). +The row of $V$ for a reference node $\hat\ell$ with direction $\hat d = a\hat n + +\sum_k \beta_k \hat t_k$ (numeric split) is assembled generically: + +* $a = 0$ (value dofs, or tangential directions): push-forward invariant, identity row. +* otherwise solve $J\hat d = x_0 C + \sum_k x_k J\hat t_k$ symbolically + (`FacetFrame.decompose`, adjugate/determinant of the polynomial frame matrix), where + $C$ = generalized cross product of the mapped tangents. The physical node has + direction $aN + \sum\beta_k J\hat t_k$ with $N = \kappa C/\|C\|$, so its coefficient + is $c = x_0\|C\|/(\kappa a)$ and the tangential remainders are $r_k = x_k - c\beta_k$ + (careful: *not* $a x_k$ — the SVD direction may be $-\hat n$, and all formulas must be + invariant under that sign flip; a sign bug here flips exactly the edges where the SVD + chose the opposite orientation). +* the remainders multiply completion functionals along *mapped reference tangents*, + which coincide with reference functionals; `Functional.evaluate` gives their numeric + expansion in the element's own nodes, and the row combination recurses through the + already-assembled rows of $V$ (entities processed in increasing dimension), which + will later let completions couple to vertex jets (Argyris/HCT) for free. + +Key facts the framework rests on: + +* **FIAT normals are "UFC consistent":** computed from the tangents by the same formula + on reference and physical cells (`UFCTriangle.compute_normal` rotates the scaled edge + tangent; `UFCTetrahedron.compute_normal` is $-2\times$ the unit cross product of the + scaled face tangents), with cell-independent magnitude. Hence $N = \kappa C/\|C\|$ + with $\kappa$ recoverable from reference data, and no orientation logic is needed + (assumes $\det J > 0$). For the record, the fully simplified closed forms the solve + reproduces are $a = \det J\sqrt{\det\hat G/\det G}$ and $b = G^{-1}T^TJ\hat n$ with + Gram matrices of the (mapped) tangents. * **Integral averages are push-forward invariant.** FIAT's Morley dofs are averages - (`FacetQuadratureRule(..., avg=True)` inside `IntegralMomentOfNormalDerivative`, and - explicit `avg=True` codim-2 moments), so value-moment nodes need identity rows and the - facet-measure Jacobians cancel from $a$ — no `physical_edge_lengths` needed anywhere. -* **Completion functionals are built from the node's own quadrature.** - `IntegralMomentOfDerivative(ref_el, node.Q, node.f_at_qpts, t)` reuses the stored rule - and weight of the normal node, guaranteeing the tangential partners carry identical - scaling conventions. + (`FacetQuadratureRule(..., avg=True)`), so physical nodes share the reference weights + and no `physical_edge_lengths` appear. The framework *assumes* measure-intrinsic + moments (the Brubeck & Kirby 2025 reference-node convention) — documented in + `finat/functional.py`. * **The conditioning $h$-scaling generalizes via `max_deriv_order`.** Column $j$ is scaled by $h_E^{-m}$ where $m$ is the derivative order of node $j$ and $h_E$ averages - `cell_size()` over the vertices of its entity. This reproduces the per-element - hand-written scalings (Morley facet dofs, Hermite/Argyris vertex jets). Note - `cell_size()` returns raw numpy values in the test mappings but GEM in Firedrake, so - use operator arithmetic (`havg**(-m)`), not explicit GEM node constructors. - -Next steps: vertex-jet groups (`PointDerivative`/`PointSecondDerivative` etc. → -`_jet_transform` blocks) to cover Hermite, then completion rows that couple to -non-invariant (jet) dofs via recursive substitution to cover HCT/Argyris, then the -extended-element path for reduced HCT/Bell. + `cell_size()` over the vertices of its entity; reproduces every per-element + hand-written scaling. `cell_size()` returns raw numpy values in the test mappings but + GEM in Firedrake, so use operator arithmetic (`havg**(-m)`), not GEM constructors + (GEM overloads `+ - * / ** @` with `Zero`/constant folding; keep numpy object arrays + on the left when scaling by a GEM scalar). + +Next steps: vertex-jet groups (order $\geq 2$ pullback = tensor powers of $J$; +point-jet groups are their own completion) to cover Hermite/Bell/Argyris vertices; +completion rows coupling to jet dofs (the recursion already supports it once jets have +rows); the extended-element path for reduced HCT/Bell; vector-valued components for +Piola-mapped elements. The implementation mirrors the theory factor by factor: diff --git a/finat/__init__.py b/finat/__init__.py index bde435a1f..11601584e 100644 --- a/finat/__init__.py +++ b/finat/__init__.py @@ -43,6 +43,7 @@ from .nodal_enriched import NodalEnrichedElement # noqa: F401 from .quadrature_element import QuadratureElement, make_quadrature_element # noqa: F401 from .restricted import RestrictedElement # noqa: F401 +from .functional import Functional # noqa: F401 from .runtime_tabulated import RuntimeTabulated # noqa: F401 from . import quadrature # noqa: F401 from . import cell_tools # noqa: F401 diff --git a/finat/functional.py b/finat/functional.py new file mode 100644 index 000000000..d5302fe7e --- /dev/null +++ b/finat/functional.py @@ -0,0 +1,177 @@ +"""Symbolic representation of degrees of freedom. + +A :class:`Functional` represents a degree of freedom in the form + +.. math:: \\ell(f) = \\sum_q w_q \\langle D, \\nabla^m f(x_q) \\rangle, + +where the points :math:`x_q` and quadrature/moment weights :math:`w_q` +are numeric, and the direction tensor :math:`D` (of rank equal to the +derivative order :math:`m`) may be numeric, for functionals defined on +the reference cell, or a GEM expression, for functionals carrying +physical geometry. + +This representation is the foundation for automating the transformation +theory of Kirby (2017): degrees of freedom of any FIAT element are +converted to this common form directly from their point and derivative +dictionaries, with the derivative direction recovered numerically, so +that no dispatch over FIAT functional types is required. + +The physical counterpart of a reference functional is assumed to share +its points and weights: integral moments must be measure-intrinsic +(e.g. integral averages), following the reference node convention of +Brubeck & Kirby (2025). +""" + +import numpy + +from FIAT.finite_element import FiniteElement +from FIAT.functional import Functional as FIATFunctional +from gem import Literal, Node + + +class Functional: + """Symbolic degree of freedom with a single derivative direction. + + Parameters + ---------- + points : + Tuple of reference-cell points. + weights : + Numeric weight for each point. + order : + The derivative order :math:`m`. + direction : + For ``order > 0``, the direction tensor of rank ``order``, + either numeric or a GEM expression; ``None`` for ``order == 0``. + """ + + def __init__(self, points: tuple, weights: numpy.ndarray, + order: int = 0, direction=None): + self.points = points + self.weights = weights + self.order = order + self.direction = direction + + @classmethod + def from_fiat(cls, node: FIATFunctional, tol: float = 1e-12) -> "Functional": + """Construct a symbolic Functional from a FIAT functional. + + The construction only inspects the point and derivative + dictionaries: the derivative order and the (common) direction of + differentiation are recovered numerically by factorizing the + matrix of derivative weights. + + Parameters + ---------- + node : + The FIAT functional. + tol : + Relative tolerance for the rank-one factorization of the + derivative weights. + + Returns + ------- + Functional + The symbolic representation of the FIAT functional. + """ + if node.pt_dict and node.deriv_dict: + raise NotImplementedError( + f"{type(node).__name__} mixes value and derivative weights.") + + if not node.deriv_dict: + points = tuple(node.pt_dict) + weights = [] + for pt in points: + (w, comp), = node.pt_dict[pt] + if comp != tuple(): + raise NotImplementedError( + f"{type(node).__name__} has vector components.") + weights.append(w) + return cls(points, numpy.asarray(weights)) + + sd = node.ref_el.get_spatial_dimension() + order = node.max_deriv_order + if order != 1: + raise NotImplementedError( + f"{type(node).__name__} has derivative order {order}.") + + points = tuple(node.deriv_dict) + W = numpy.zeros((len(points), sd)) + for q, pt in enumerate(points): + for w, alpha, comp in node.deriv_dict[pt]: + if comp != tuple(): + raise NotImplementedError( + f"{type(node).__name__} has vector components.") + k, = numpy.flatnonzero(alpha) + W[q, k] += w + + # Factor the weights as a common direction times scalar weights + u, s, vt = numpy.linalg.svd(W) + if any(s[1:] > tol * s[0]): + raise NotImplementedError( + f"{type(node).__name__} has no common derivative direction.") + direction = vt[0] + weights = u[:, 0] * s[0] + return cls(points, weights, order=1, direction=direction) + + def with_direction(self, direction) -> "Functional": + """Return the same functional with another direction tensor.""" + return type(self)(self.points, self.weights, + order=self.order, direction=direction) + + def pullback(self, J: Node) -> "Functional": + """View this reference functional as acting on physical functions. + + By the chain rule, reference derivatives of a pullback are + physical derivatives contracted with the Jacobian, so the + direction tensor maps covariantly: each slot is contracted + with :math:`J`. + + Parameters + ---------- + J : + GEM expression for the cell Jacobian. + + Returns + ------- + Functional + The functional with direction :math:`J \\otimes \\dots + \\otimes J : D`, acting on physical derivatives at the + images of the reference points. + """ + if self.order == 0: + return self + elif self.order == 1: + return self.with_direction(J @ Literal(self.direction)) + else: + raise NotImplementedError( + f"Pullback of derivative order {self.order} not implemented.") + + def evaluate(self, fiat_element: FiniteElement) -> numpy.ndarray: + """Apply this functional to the nodal basis of a FIAT element. + + This is the generalized Vandermonde computation: the restriction + of a functional :math:`\\ell` to the polynomial space satisfies + :math:`\\pi \\ell = \\sum_j \\ell(\\psi_j)\\, \\pi n_j`. Only + valid for functionals with numeric direction. + + Parameters + ---------- + fiat_element : + The FIAT element providing the nodal basis. + + Returns + ------- + numpy.ndarray + The vector of values of this functional on the nodal basis. + """ + sd = fiat_element.get_reference_element().get_spatial_dimension() + tab = fiat_element.tabulate(self.order, self.points) + row = 0 + for index in numpy.ndindex((sd,) * self.order): + alpha = [0] * sd + for k in index: + alpha[k] += 1 + coef = self.direction[index] if self.order else 1 + row = row + coef * (tab[tuple(alpha)] @ self.weights) + return row diff --git a/finat/morley.py b/finat/morley.py index ca3e89e88..c8cd20cf1 100644 --- a/finat/morley.py +++ b/finat/morley.py @@ -1,83 +1,22 @@ import FIAT -import numpy - -from gem import ListTensor, partial_indexed, Literal, Power +from gem import ListTensor from finat.citations import cite from finat.fiat_elements import ScalarFiatElement -from finat.physically_mapped import identity, PhysicallyMappedElement - - -def morley_transform(cell, J, detJ, face): - adjugate = lambda A: ListTensor([[A[1, 1], -1*A[1, 0]], [-1*A[0, 1], A[0, 0]]]) - sd = cell.get_spatial_dimension() - thats = cell.compute_tangents(sd-1, face) - nhat = numpy.cross(*thats) - ahat = numpy.linalg.norm(nhat) - nhat /= numpy.dot(nhat, nhat) - - Jn = J @ Literal(nhat) - Jt = J @ Literal(thats.T) - Gnt = Jn.T @ Jt - Gtt = Jt.T @ Jt - detG = Gtt[0, 0]*Gtt[1, 1] - Gtt[0, 1]*Gtt[1, 0] - area = Power(detG, Literal(0.5)) - - Bnn = detJ / area - Bnt = Gnt @ adjugate(Gtt) / detG - Bnn *= ahat - Bnt *= ahat - Bnt = (-1*(Bnt[0] + Bnt[1]), Bnt[0], Bnt[1]) - return Bnn, Bnt +from finat.physically_mapped import PhysicalGeometry, PhysicallyMappedElement +from finat.zany import zany_basis_transformation class Morley(PhysicallyMappedElement, ScalarFiatElement): + """The Morley element on simplices of any dimension. + + The basis transformation is derived automatically from the FIAT + dual basis by :func:`finat.zany.zany_basis_transformation`. + """ def __init__(self, cell, degree=2): cite("Morley1971") cite("MingXu2006") super().__init__(FIAT.Morley(cell, degree=degree)) - def basis_transformation(self, coordinate_mapping): - sd = self.cell.get_spatial_dimension() - top = self.cell.get_topology() - # Jacobians at barycenter - bary, = self.cell.make_points(sd, 0, sd+1) - J = coordinate_mapping.jacobian_at(bary) - detJ = coordinate_mapping.detJ_at(bary) - V = identity(self.space_dimension()) - - offset = len(top[sd-2]) - if sd == 2: - pel = coordinate_mapping.physical_edge_lengths() - pts = coordinate_mapping.physical_tangents() - pns = coordinate_mapping.physical_normals() - for e in top[sd-1]: - s = offset + e - t = partial_indexed(pts, (e,)) - n = partial_indexed(pns, (e,)) - nhat = self.cell.compute_normal(e) - Jn = J @ Literal(nhat) - Bnn = Jn @ n - Bnt = Jn @ t - V[s, s] = Bnn - v = list(top[sd-1][e]) - V[s, v] = Bnt / pel[e] - V[s, v[0]] *= -1 - - else: - edges = self.cell.get_connectivity()[(sd-1, sd-2)] - for face in top[sd-1]: - Bnn, Bnt = morley_transform(self.cell, J, detJ, face) - fid = offset + face - V[fid, fid] = Bnn - V[fid, list(edges[face])] = Bnt - - # diagonal post-scaling to patch up conditioning - h = coordinate_mapping.cell_size() - for face in top[sd-1]: - s = offset + face - verts = top[sd-1][face] - havg = sum(h[v] for v in verts) / len(verts) - V[:, s] *= 1/havg - - return ListTensor(V.T) + def basis_transformation(self, coordinate_mapping: PhysicalGeometry) -> ListTensor: + return zany_basis_transformation(self._element, coordinate_mapping) diff --git a/finat/walkington.py b/finat/walkington.py index b9d11ae38..c041b24ff 100644 --- a/finat/walkington.py +++ b/finat/walkington.py @@ -2,17 +2,39 @@ import numpy from FIAT.polynomial_set import mis -from gem import ListTensor, Zero +from gem import ListTensor, Literal, Power, Zero from finat.citations import cite from finat.fiat_elements import ScalarFiatElement from finat.physically_mapped import identity, PhysicallyMappedElement from finat.argyris import _vertex_transform, _normal_tangential_transform -from finat.morley import morley_transform from copy import deepcopy from itertools import chain +def morley_transform(cell, J, detJ, face): + adjugate = lambda A: ListTensor([[A[1, 1], -1*A[1, 0]], [-1*A[0, 1], A[0, 0]]]) + sd = cell.get_spatial_dimension() + thats = cell.compute_tangents(sd-1, face) + nhat = numpy.cross(*thats) + ahat = numpy.linalg.norm(nhat) + nhat /= numpy.dot(nhat, nhat) + + Jn = J @ Literal(nhat) + Jt = J @ Literal(thats.T) + Gnt = Jn.T @ Jt + Gtt = Jt.T @ Jt + detG = Gtt[0, 0]*Gtt[1, 1] - Gtt[0, 1]*Gtt[1, 0] + area = Power(detG, Literal(0.5)) + + Bnn = detJ / area + Bnt = Gnt @ adjugate(Gtt) / detG + Bnn *= ahat + Bnt *= ahat + Bnt = (-1*(Bnt[0] + Bnt[1]), Bnt[0], Bnt[1]) + return Bnn, Bnt + + class Walkington(PhysicallyMappedElement, ScalarFiatElement): def __init__(self, cell, degree=5): cite("Walkington2010") diff --git a/finat/zany.py b/finat/zany.py index 2fb030283..7db3becf6 100644 --- a/finat/zany.py +++ b/finat/zany.py @@ -7,19 +7,17 @@ physical nodes, so that the physical basis functions are obtained as :math:`M F^*(\\hat\\Psi)` with :math:`M = V^T`. -The construction follows the factorization :math:`V = E V^c D`: - -* each reference node is pulled back to the physical cell and expanded - by the chain rule in a frame adapted to its entity: the physical - direction appearing in the corresponding physical node (e.g. the - physical facet normal) completed with the push-forwards of the - reference tangents (the :math:`V^c` factor and the extraction - :math:`E`); -* the completion functionals are derivatives along *mapped* reference - tangents, so they coincide with reference functionals whose expansion - in the element's own nodes is a purely numeric generalized Vandermonde - row (the :math:`D` factor), computed by dual evaluation instead of - hand-derived univariate exactness rules. +Degrees of freedom are represented symbolically by +:class:`finat.functional.Functional` and processed generically, without +dispatching over FIAT functional types. Each reference node is pulled +back to the physical cell by the chain rule and expanded in the frame +of the physical facet normal and the mapped reference tangents; the +tangential components are derivatives along *mapped* reference tangents +and therefore coincide with reference functionals, whose expansion in +the element's own nodes is a purely numeric generalized Vandermonde +row. In the language of the theory, the frame expansion realizes +:math:`E V^c` and the numeric elimination of the tangential completion +realizes :math:`D`. """ from functools import reduce @@ -28,71 +26,50 @@ import numpy from FIAT.finite_element import FiniteElement -from FIAT.functional import (Functional, IntegralMoment, - IntegralMomentOfDerivative, - IntegralMomentOfNormalDerivative, - PointEvaluation) -from gem import Literal, ListTensor, Node, Power +from gem import Literal, ListTensor, Node, Power, Zero +from finat.functional import Functional from finat.physically_mapped import (PhysicalGeometry, adjugate, determinant, identity) -def dual_evaluation_matrix(fiat_element: FiniteElement, - functionals: list[Functional]) -> numpy.ndarray: - """Evaluate functionals against the nodal basis of a FIAT element. - - This is the generalized Vandermonde computation providing the - numeric coefficients of the :math:`D` factor: the restriction of a - functional :math:`\\ell` to the polynomial space :math:`P` satisfies - :math:`\\pi \\ell = \\sum_j \\ell(\\psi_j)\\, \\pi n_j`. +def generalized_cross(tangents) -> numpy.ndarray: + """Generalized cross product of d-1 vectors in d dimensions. Parameters ---------- - fiat_element : - The FIAT element providing the nodal basis. - functionals : - Functionals to evaluate. + tangents : + A (d-1, d) array of vectors, with numeric or GEM entries. Returns ------- numpy.ndarray - Matrix with entry (k, j) equal to the k-th functional applied - to the j-th nodal basis function. + The vector :math:`C` such that :math:`C \\cdot w = + \\det([t_1; \\dots; t_{d-1}; w])` for all :math:`w`; it is + orthogonal to every :math:`t_k`. """ - poly_set = fiat_element.get_nodal_basis() - coeffs = poly_set.get_coeffs() - riesz = numpy.array([f.to_riesz(poly_set).flatten() for f in functionals]) - return riesz @ coeffs.reshape(coeffs.shape[0], -1).T - - -def is_invariant(node: Functional) -> bool: - """Return whether a functional is preserved under push-forward. - - Point evaluations and integral moments of the function value against - an intrinsically defined weight (constructed from the same - reference-facet quadrature rule on both cells) satisfy - :math:`F_*(n) = \\hat{n}`, so their rows of :math:`V` are identity. - """ - return type(node) in {PointEvaluation, IntegralMoment} - - -def _normal_tangential_frame(fiat_element: FiniteElement, entity: int, - J: Node, detJ: Node) -> tuple: - """Chain-rule data for the facet normal/tangential frame. - - The pullback of the reference normal-derivative direction expands in - the frame of the physical facet normal and the mapped reference - tangents, - - .. math:: J\\hat{n} = a\\, n + \\sum_k b_k\\, J\\hat{t}_k. - - Orthogonality of the physical normal to the mapped tangents and the - identical (tangent-based) normal convention on both cells reduce the - coefficients to Gram-matrix algebra: - :math:`a = \\det J \\sqrt{\\det\\hat{G}/\\det G}` and - :math:`b = G^{-1} T^T J\\hat{n}` with :math:`T = [J\\hat{t}_k]` and - Gram matrices :math:`G = T^T T`, :math:`\\hat{G}_{kl} = \\hat{t}_k - \\cdot \\hat{t}_l`. + A = numpy.asarray(tangents) + d = A.shape[1] + cols = numpy.ones(d, dtype=bool) + C = [] + for i in range(d): + cols[i] = False + C.append((-1) ** (d - 1 + i) * determinant(A[:, cols])) + cols[i] = True + return numpy.asarray(C) + + +class FacetFrame: + """Normal/tangential frame of a facet and its push-forward. + + The reference frame consists of the FIAT facet normal + :math:`\\hat{n}` and the scaled facet tangents :math:`\\hat{t}_k`; + the physical frame consists of the physical facet normal and the + mapped tangents :math:`J\\hat{t}_k`. Because FIAT normals are + computed from the tangents by the same formula on the reference and + physical cells, the physical normal is :math:`\\kappa\\, C / \\|C\\|` + with :math:`C` the generalized cross product of the mapped tangents + and :math:`\\kappa` a cell-independent constant recovered from the + reference data. Parameters ---------- @@ -102,83 +79,66 @@ def _normal_tangential_frame(fiat_element: FiniteElement, entity: int, The facet number. J : GEM expression for the cell Jacobian. - detJ : - GEM expression for the Jacobian determinant. - - Returns - ------- - tuple - ``(a, b)`` with ``a`` the GEM coefficient of the physical - normal node and ``b`` the list of GEM coefficients of the - mapped tangential functionals. - """ - ref_el = fiat_element.get_reference_element() - sd = ref_el.get_spatial_dimension() - that = ref_el.compute_tangents(sd - 1, entity) - nhat = ref_el.compute_normal(entity) - - Jn = J @ Literal(nhat) - Jt = [J @ Literal(t) for t in that] - G = numpy.array([[Jt[k] @ Jt[l] for l in range(sd - 1)] - for k in range(sd - 1)], dtype=object) - detG = determinant(G) - adjG = adjugate(G) - Tn = [Jt[k] @ Jn for k in range(sd - 1)] - b = [reduce(add, (adjG[k, l] * Tn[l] for l in range(sd - 1))) / detG - for k in range(sd - 1)] - - Ghat = numpy.dot(that, that.T) - a = detJ * Literal(numpy.linalg.det(Ghat) ** 0.5) / Power(detG, Literal(0.5)) - return a, b - - -def _facet_normal_moment_rows(V: numpy.ndarray, dofs: list, - fiat_element: FiniteElement, entity: int, - J: Node, detJ: Node, invariant: set, - tol: float) -> None: - """Fill the rows of V for normal-derivative moments on a facet. - - Parameters - ---------- - V : - Object array being assembled, with entry (i, j) relating - reference node i to the push-forward of physical node j. - dofs : - Indices of the normal-derivative moment nodes on this facet. - fiat_element : - The FIAT element. - entity : - The facet number. - J, detJ : - GEM expressions for the cell Jacobian and its determinant. - invariant : - Indices of the push-forward invariant nodes. - tol : - Tolerance for detecting zeros in the numeric completion - coefficients. """ - ref_el = fiat_element.get_reference_element() - sd = ref_el.get_spatial_dimension() - that = ref_el.compute_tangents(sd - 1, entity) - nodes = fiat_element.dual_basis() - - a, b = _normal_tangential_frame(fiat_element, entity, J, detJ) - # Nodal completion: same integral moment rule, tangential directions. - completion = [IntegralMomentOfDerivative(ref_el, nodes[i].Q, - nodes[i].f_at_qpts, t) - for i in dofs for t in that] - C = dual_evaluation_matrix(fiat_element, completion) - C[abs(C) < tol] = 0 - - for row, (i, bk) in zip(C, ((i, bk) for i in dofs for bk in b)): - V[i, i] = a - for j in numpy.flatnonzero(row): - if j not in invariant: - raise NotImplementedError( - f"Completion of node {i} couples to node {j} of type " - f"{type(nodes[j]).__name__}, which is not yet handled.") - V[i, j] = V[i, j] + Literal(row[j]) * bk + def __init__(self, fiat_element: FiniteElement, entity: int, J: Node): + ref_el = fiat_element.get_reference_element() + sd = ref_el.get_spatial_dimension() + self.tangents = ref_el.compute_tangents(sd - 1, entity) + self.normal = ref_el.compute_normal(entity) + + Chat = generalized_cross(self.tangents) + kappa = self.normal @ Chat / numpy.linalg.norm(Chat) + + self.mapped_tangents = [J @ Literal(t) for t in self.tangents] + C = generalized_cross([[Jt[i] for i in range(sd)] + for Jt in self.mapped_tangents]) + A = numpy.empty((sd, sd), dtype=object) + A[:, 0] = C + for k, Jt in enumerate(self.mapped_tangents): + A[:, k + 1] = [Jt[i] for i in range(sd)] + self._adjA = adjugate(A) + self._detA = determinant(A) + + normC = Power(reduce(add, (C[i] * C[i] for i in range(sd))), + Literal(0.5)) + self.normal_scale = normC / kappa + + def reference_coefficients(self, direction: numpy.ndarray) -> numpy.ndarray: + """Expand a numeric direction in the reference frame. + + Parameters + ---------- + direction : + A numeric direction vector. + + Returns + ------- + numpy.ndarray + Coefficients ``(a, b_1, ..., b_{d-1})`` such that the + direction equals :math:`a\\hat{n} + \\sum_k b_k \\hat{t}_k`. + """ + A = numpy.column_stack([self.normal, *self.tangents]) + return numpy.linalg.solve(A, direction) + + def decompose(self, direction: Node) -> list: + """Expand a GEM direction in the un-normalized physical frame. + + Parameters + ---------- + direction : + A GEM direction vector. + + Returns + ------- + list + GEM coefficients ``(x_0, x_1, ..., x_{d-1})`` such that the + direction equals :math:`x_0 C + \\sum_k x_k J\\hat{t}_k`. + """ + sd = self._adjA.shape[0] + return [reduce(add, (self._adjA[m, i] * direction[i] + for i in range(sd))) / self._detA + for m in range(sd)] def _conditioning_scaling(V: numpy.ndarray, fiat_element: FiniteElement, @@ -227,8 +187,7 @@ def zany_basis_transformation(fiat_element: FiniteElement, coordinate_mapping : Object providing the physical geometry as GEM expressions. tol : - Tolerance for detecting zeros in the numeric completion - coefficients. + Tolerance for detecting zeros in the numeric coefficients. Returns ------- @@ -240,26 +199,54 @@ def zany_basis_transformation(fiat_element: FiniteElement, sd = ref_el.get_spatial_dimension() bary, = ref_el.make_points(sd, 0, sd + 1) J = coordinate_mapping.jacobian_at(bary) - detJ = coordinate_mapping.detJ_at(bary) nodes = fiat_element.dual_basis() - invariant = {i for i, node in enumerate(nodes) if is_invariant(node)} - V = identity(fiat_element.space_dimension()) + + processed = set() entity_ids = fiat_element.entity_dofs() - for dim in entity_ids: - for entity in entity_ids[dim]: - dofs = [i for i in entity_ids[dim][entity] if i not in invariant] - if not dofs: - continue - if all(isinstance(nodes[i], IntegralMomentOfNormalDerivative) - for i in dofs): - _facet_normal_moment_rows(V, dofs, fiat_element, entity, - J, detJ, invariant, tol) - else: - unhandled = {type(nodes[i]).__name__ for i in dofs} - raise NotImplementedError( - f"Cannot yet transform nodes of type {unhandled}.") + for dim in sorted(entity_ids): + for entity in sorted(entity_ids[dim]): + frame = None + for i in entity_ids[dim][entity]: + ell = Functional.from_fiat(nodes[i]) + if ell.order == 0: + # Value functionals are push-forward invariant + processed.add(i) + continue + if dim != sd - 1: + raise NotImplementedError( + "Derivative nodes are only handled on facets.") + if frame is None: + frame = FacetFrame(fiat_element, entity, J) + + # Split the direction into normal and tangential parts + a, *beta = frame.reference_coefficients(ell.direction) + if abs(a) < tol: + # Mapped tangential derivatives are invariant + processed.add(i) + continue + + # Expand the pulled-back node in the physical frame + x = frame.decompose(ell.pullback(J).direction) + c = x[0] * frame.normal_scale / a + row = numpy.full(len(nodes), Zero(), dtype=object) + row[i] = c + # Eliminate the tangential completion functionals, which + # coincide with reference functionals with numeric + # coefficients on already transformed nodes + for k, that in enumerate(frame.tangents): + r = x[k + 1] - c * beta[k] + coefficients = ell.with_direction(that).evaluate(fiat_element) + coefficients[abs(coefficients) < tol] = 0 + for j in numpy.flatnonzero(coefficients): + if j not in processed: + raise NotImplementedError( + f"Completion of node {i} couples to node {j}, " + "which has not been transformed yet.") + row = row + V[j, :] * (r * coefficients[j]) + V[i, :] = row + processed.add(i) _conditioning_scaling(V, fiat_element, coordinate_mapping) return ListTensor(V.T) diff --git a/test/finat/test_zany_automation.py b/test/finat/test_zany_automation.py index 8a541356b..54c266cb4 100644 --- a/test/finat/test_zany_automation.py +++ b/test/finat/test_zany_automation.py @@ -1,34 +1,57 @@ -import finat +import FIAT import numpy as np import pytest from gem.interpreter import evaluate +from finat.functional import Functional from finat.zany import zany_basis_transformation -class AutoMorley(finat.Morley): - """Morley element with automatically derived basis transformation.""" - def basis_transformation(self, coordinate_mapping): - return zany_basis_transformation(self._element, coordinate_mapping) - - -auto_elements = { - AutoMorley: finat.Morley, -} - - -@pytest.mark.parametrize("element", auto_elements) @pytest.mark.parametrize("dimension", [2, 3]) -def test_auto_transformation(check_zany_mapping, ref_to_phys, element, dimension): - check_zany_mapping(element, ref_to_phys[dimension]) +def test_functional_from_fiat(dimension): + """The symbolic Functional recovers order, weights and direction + numerically from the FIAT functional dictionaries.""" + cell = FIAT.ufc_simplex(dimension) + element = FIAT.Morley(cell) + entity_ids = element.entity_dofs() + nodes = element.dual_basis() + + for i in entity_ids[dimension - 2][0]: + ell = Functional.from_fiat(nodes[i]) + assert ell.order == 0 + assert ell.direction is None + + for entity in entity_ids[dimension - 1]: + for i in entity_ids[dimension - 1][entity]: + ell = Functional.from_fiat(nodes[i]) + assert ell.order == 1 + normal = cell.compute_normal(entity) + cosine = ell.direction @ normal + assert np.isclose(abs(cosine), np.linalg.norm(normal)) -@pytest.mark.parametrize("element", auto_elements) @pytest.mark.parametrize("dimension", [2, 3]) -def test_auto_matches_handcoded(scaled_ref_to_phys, element, dimension): - handcoded = auto_elements[element] - for mapping in scaled_ref_to_phys[dimension]: - cell = mapping.ref_cell - Ma = evaluate([element(cell).basis_transformation(mapping)])[0].arr - Mh = evaluate([handcoded(cell).basis_transformation(mapping)])[0].arr - assert np.allclose(Ma, Mh, atol=1e-14) +def test_conditioning_scaling(ref_to_phys, scaled_ref_to_phys, dimension): + """Derivative dofs are rescaled by cell size to the power of the + derivative order.""" + scaled = scaled_ref_to_phys[dimension][-1] + # the same geometric mapping with unit cell size + unit = type(ref_to_phys[dimension])(scaled.ref_cell, scaled.phys_cell) + element = FIAT.Morley(scaled.ref_cell) + + Ms = evaluate([zany_basis_transformation(element, scaled)])[0].arr + Mu = evaluate([zany_basis_transformation(element, unit)])[0].arr + + h = scaled.cell_size()[0] + assert not np.isclose(h, 1) + orders = [node.max_deriv_order for node in element.dual_basis()] + expected = Mu * np.asarray([h**-order for order in orders])[:, None] + assert np.allclose(Ms, expected) + + +def test_unsupported_nodes(ref_to_phys): + """Vertex derivative nodes are not handled yet.""" + mapping = ref_to_phys[2] + element = FIAT.CubicHermite(mapping.ref_cell) + with pytest.raises(NotImplementedError): + zany_basis_transformation(element, mapping) From 179a3e4c33f0e7658b30a70813526445908ee1b4 Mon Sep 17 00:00:00 2001 From: Pablo Brubeck Date: Mon, 13 Jul 2026 11:01:38 +0100 Subject: [PATCH 05/34] Reimplement Hermite on the automatic transformation framework Derivative nodes away from facets have no geometric frame: FIAT keeps Cartesian directions on the physical cell, so the group of derivative nodes on an entity acts as its own completion (this is exactly the affine-interpolation equivalent case). The pulled-back direction is expanded in the group's own numeric direction basis, with weight-ratio factors keeping the expansion invariant under the scale and sign ambiguity of each node's recovered direction factorization. finat.Hermite now derives its transformation automatically; the matrix matches the deleted hand-coded one exactly in 2D and 3D, scaled and unscaled. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 31 ++++-- finat/hermite.py | 29 ++---- finat/zany.py | 159 +++++++++++++++++++++-------- test/finat/test_zany_automation.py | 18 ++-- 4 files changed, 159 insertions(+), 78 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2cdd9aecf..8292690d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -160,12 +160,12 @@ the theory factor by factor, not merely reproduce matrix entries: **Status (2026-07-13).** The framework lives in `finat/functional.py` (the symbolic `finat.Functional`) and `finat/zany.py` (`FacetFrame`, `zany_basis_transformation`). -`finat.Morley` is reimplemented on it: `basis_transformation` is one call to -`zany_basis_transformation(self._element, coordinate_mapping)`, working in 2D and 3D -through a single dimension-independent code path, verified by `check_zany_mapping` -and matching the previous hand-coded matrices (including the $h$-scaling) to machine -precision before that code was deleted. `morley_transform` moved verbatim to -`finat/walkington.py`, its only remaining user. Tests: +`finat.Morley` and `finat.Hermite` are reimplemented on it: `basis_transformation` is +one call to `zany_basis_transformation(self._element, coordinate_mapping)`, working in +2D and 3D through a single dimension-independent code path, verified by +`check_zany_mapping` and matching the previous hand-coded matrices (including the +$h$-scaling) to machine precision before that code was deleted. `morley_transform` +moved verbatim to `finat/walkington.py`, its only remaining user. Tests: `test/finat/test_zany_automation.py`; `check_zany_mapping` lives in the finat conftest and is provided to test modules as a pytest fixture (pytest runs with `--import-mode=importlib`, so test modules cannot import from each other or from @@ -197,6 +197,15 @@ The row of $V$ for a reference node $\hat\ell$ with direction $\hat d = a\hat n already-assembled rows of $V$ (entities processed in increasing dimension), which will later let completions couple to vertex jets (Argyris/HCT) for free. +Derivative nodes *away from facets* (`_point_jet_rows`, covering Hermite vertex +gradients) have no geometric frame: FIAT keeps Cartesian directions on the physical +cell, so the group of derivative nodes on the entity acts as its own completion — this +is precisely affine-interpolation equivalence. The pulled-back direction $J\hat d_i$ +is expanded in the group's own (numeric) direction basis, with weight-ratio factors +making the expansion invariant under the SVD scale/sign ambiguity of each node's +recovered $(w, D)$ factorization. The group must span the derivative jet, share its +points, and have pairwise-parallel weights; otherwise `NotImplementedError`. + Key facts the framework rests on: * **FIAT normals are "UFC consistent":** computed from the tangents by the same formula @@ -220,11 +229,11 @@ Key facts the framework rests on: (GEM overloads `+ - * / ** @` with `Zero`/constant folding; keep numpy object arrays on the left when scaling by a GEM scalar). -Next steps: vertex-jet groups (order $\geq 2$ pullback = tensor powers of $J$; -point-jet groups are their own completion) to cover Hermite/Bell/Argyris vertices; -completion rows coupling to jet dofs (the recursion already supports it once jets have -rows); the extended-element path for reduced HCT/Bell; vector-valued components for -Piola-mapped elements. +Next steps: second and higher derivative orders (pullback = tensor powers of $J$; +symmetric direction tensors in `Functional`) to cover Bell/Argyris vertex jets; +facet-derivative completion rows coupling to jet dofs (the recursion already supports +it once jets have rows); the extended-element path for reduced HCT/Bell; vector-valued +components for Piola-mapped elements. The implementation mirrors the theory factor by factor: diff --git a/finat/hermite.py b/finat/hermite.py index 5b3a28115..153d0e947 100644 --- a/finat/hermite.py +++ b/finat/hermite.py @@ -3,30 +3,19 @@ from finat.citations import cite from finat.fiat_elements import ScalarFiatElement -from finat.physically_mapped import identity, PhysicallyMappedElement +from finat.physically_mapped import PhysicalGeometry, PhysicallyMappedElement +from finat.zany import zany_basis_transformation class Hermite(PhysicallyMappedElement, ScalarFiatElement): + """The cubic Hermite element. + + The basis transformation is derived automatically from the FIAT + dual basis by :func:`finat.zany.zany_basis_transformation`. + """ def __init__(self, cell, degree=3): cite("Ciarlet1972") super().__init__(FIAT.CubicHermite(cell)) - def basis_transformation(self, coordinate_mapping): - Js = [coordinate_mapping.jacobian_at(vertex) - for vertex in self.cell.get_vertices()] - - h = coordinate_mapping.cell_size() - - d = self.cell.get_dimension() - M = identity(self.space_dimension()) - - cur = 0 - for i in range(d+1): - cur += 1 # skip the vertex - J = Js[i] - for j in range(d): - for k in range(d): - M[cur+j, cur+k] = J[j, k] / h[i] - cur += d - - return ListTensor(M) + def basis_transformation(self, coordinate_mapping: PhysicalGeometry) -> ListTensor: + return zany_basis_transformation(self._element, coordinate_mapping) diff --git a/finat/zany.py b/finat/zany.py index 7db3becf6..24317ac9b 100644 --- a/finat/zany.py +++ b/finat/zany.py @@ -141,6 +141,14 @@ def decompose(self, direction: Node) -> list: for m in range(sd)] +def _weight_ratio(wi: numpy.ndarray, wj: numpy.ndarray, tol: float) -> float: + """Return the scalar s with wi == s * wj, if it exists.""" + s = wi @ wj / (wj @ wj) + if not numpy.allclose(wi, s * wj, atol=tol * numpy.linalg.norm(wi)): + raise NotImplementedError("Weights are not parallel.") + return s + + def _conditioning_scaling(V: numpy.ndarray, fiat_element: FiniteElement, coordinate_mapping: PhysicalGeometry) -> None: """Rescale derivative degrees of freedom by the cell size. @@ -207,46 +215,117 @@ def zany_basis_transformation(fiat_element: FiniteElement, entity_ids = fiat_element.entity_dofs() for dim in sorted(entity_ids): for entity in sorted(entity_ids[dim]): - frame = None - for i in entity_ids[dim][entity]: - ell = Functional.from_fiat(nodes[i]) - if ell.order == 0: - # Value functionals are push-forward invariant - processed.add(i) - continue - if dim != sd - 1: - raise NotImplementedError( - "Derivative nodes are only handled on facets.") - if frame is None: - frame = FacetFrame(fiat_element, entity, J) - - # Split the direction into normal and tangential parts - a, *beta = frame.reference_coefficients(ell.direction) - if abs(a) < tol: - # Mapped tangential derivatives are invariant - processed.add(i) - continue - - # Expand the pulled-back node in the physical frame - x = frame.decompose(ell.pullback(J).direction) - c = x[0] * frame.normal_scale / a - row = numpy.full(len(nodes), Zero(), dtype=object) - row[i] = c - # Eliminate the tangential completion functionals, which - # coincide with reference functionals with numeric - # coefficients on already transformed nodes - for k, that in enumerate(frame.tangents): - r = x[k + 1] - c * beta[k] - coefficients = ell.with_direction(that).evaluate(fiat_element) - coefficients[abs(coefficients) < tol] = 0 - for j in numpy.flatnonzero(coefficients): - if j not in processed: - raise NotImplementedError( - f"Completion of node {i} couples to node {j}, " - "which has not been transformed yet.") - row = row + V[j, :] * (r * coefficients[j]) - V[i, :] = row - processed.add(i) + ells = {i: Functional.from_fiat(nodes[i]) + for i in entity_ids[dim][entity]} + # Value functionals are push-forward invariant + processed.update(i for i, ell in ells.items() if ell.order == 0) + group = {i: ell for i, ell in ells.items() if ell.order > 0} + if not group: + continue + if dim == sd - 1: + _facet_rows(V, group, fiat_element, entity, J, processed, tol) + else: + _point_jet_rows(V, group, J, processed, tol) _conditioning_scaling(V, fiat_element, coordinate_mapping) return ListTensor(V.T) + + +def _facet_rows(V: numpy.ndarray, group: dict, fiat_element: FiniteElement, + entity: int, J: Node, processed: set, tol: float) -> None: + """Assemble the rows of V for derivative nodes on a facet. + + Physical facet nodes take their normal component along the physical + facet normal and their tangential components along the mapped + reference tangents. The pulled-back reference node is expanded in + this frame, and the tangential remainders, being derivatives along + mapped reference tangents, coincide with reference functionals that + are eliminated numerically through already assembled rows of V. + + Parameters + ---------- + V : + Object array being assembled. + group : + Mapping from node index to symbolic Functional for the + derivative nodes on this facet. + fiat_element : + The FIAT element. + entity : + The facet number. + J : + GEM expression for the cell Jacobian. + processed : + Indices of the already assembled rows; updated in place. + tol : + Tolerance for detecting zeros in the numeric coefficients. + """ + frame = FacetFrame(fiat_element, entity, J) + for i, ell in group.items(): + # Split the direction into normal and tangential parts + a, *beta = frame.reference_coefficients(ell.direction) + if abs(a) < tol: + # Mapped tangential derivatives are invariant + processed.add(i) + continue + + # Expand the pulled-back node in the physical frame + x = frame.decompose(ell.pullback(J).direction) + c = x[0] * frame.normal_scale / a + row = numpy.full(V.shape[1], Zero(), dtype=object) + row[i] = c + for k, that in enumerate(frame.tangents): + r = x[k + 1] - c * beta[k] + coefficients = ell.with_direction(that).evaluate(fiat_element) + coefficients[abs(coefficients) < tol] = 0 + for j in numpy.flatnonzero(coefficients): + if j not in processed: + raise NotImplementedError( + f"Completion of node {i} couples to node {j}, " + "which has not been transformed yet.") + row = row + V[j, :] * (r * coefficients[j]) + V[i, :] = row + processed.add(i) + + +def _point_jet_rows(V: numpy.ndarray, group: dict, J: Node, + processed: set, tol: float) -> None: + """Assemble the rows of V for derivative nodes away from facets. + + Away from facets there is no geometric frame, and physical nodes + keep the reference (Cartesian) directions, so the group must span + all directions and acts as its own completion: this is the + affine-interpolation equivalent case, and each pulled-back node is + expanded within the group. + + Parameters + ---------- + V : + Object array being assembled. + group : + Mapping from node index to symbolic Functional for the + derivative nodes on this entity. + J : + GEM expression for the cell Jacobian. + processed : + Indices of the already assembled rows; updated in place. + tol : + Tolerance for detecting zeros in the numeric coefficients. + """ + directions = numpy.array([ell.direction for ell in group.values()]) + if len(set(ell.points for ell in group.values())) > 1: + raise NotImplementedError("Group nodes at different points.") + if directions.shape[0] != directions.shape[1]: + raise NotImplementedError( + "Directions do not span the derivative jet.") + + # coefficients of the direction basis expansion of each Cartesian axis + Dinv = numpy.linalg.inv(directions.T) + for i, ell in group.items(): + Jd = ell.pullback(J).direction + for col, (j, ellj) in enumerate(group.items()): + s = _weight_ratio(ell.weights, ellj.weights, tol) + x = s * Dinv[col] + nz = numpy.flatnonzero(abs(x) > tol) + V[i, j] = reduce(add, (Jd[m] * x[m] for m in nz)) if len(nz) else Zero() + processed.add(i) diff --git a/test/finat/test_zany_automation.py b/test/finat/test_zany_automation.py index 54c266cb4..e44aeaa25 100644 --- a/test/finat/test_zany_automation.py +++ b/test/finat/test_zany_automation.py @@ -30,28 +30,32 @@ def test_functional_from_fiat(dimension): assert np.isclose(abs(cosine), np.linalg.norm(normal)) +auto_elements = [FIAT.Morley, FIAT.CubicHermite] + + +@pytest.mark.parametrize("element", auto_elements) @pytest.mark.parametrize("dimension", [2, 3]) -def test_conditioning_scaling(ref_to_phys, scaled_ref_to_phys, dimension): +def test_conditioning_scaling(ref_to_phys, scaled_ref_to_phys, element, dimension): """Derivative dofs are rescaled by cell size to the power of the derivative order.""" scaled = scaled_ref_to_phys[dimension][-1] # the same geometric mapping with unit cell size unit = type(ref_to_phys[dimension])(scaled.ref_cell, scaled.phys_cell) - element = FIAT.Morley(scaled.ref_cell) + fiat_element = element(scaled.ref_cell) - Ms = evaluate([zany_basis_transformation(element, scaled)])[0].arr - Mu = evaluate([zany_basis_transformation(element, unit)])[0].arr + Ms = evaluate([zany_basis_transformation(fiat_element, scaled)])[0].arr + Mu = evaluate([zany_basis_transformation(fiat_element, unit)])[0].arr h = scaled.cell_size()[0] assert not np.isclose(h, 1) - orders = [node.max_deriv_order for node in element.dual_basis()] + orders = [node.max_deriv_order for node in fiat_element.dual_basis()] expected = Mu * np.asarray([h**-order for order in orders])[:, None] assert np.allclose(Ms, expected) def test_unsupported_nodes(ref_to_phys): - """Vertex derivative nodes are not handled yet.""" + """Second derivative nodes are not handled yet.""" mapping = ref_to_phys[2] - element = FIAT.CubicHermite(mapping.ref_cell) + element = FIAT.Bell(mapping.ref_cell) with pytest.raises(NotImplementedError): zany_basis_transformation(element, mapping) From a865300af07f8306510c276573660de68285ec5d Mon Sep 17 00:00:00 2001 From: Pablo Brubeck Date: Mon, 13 Jul 2026 12:48:16 +0100 Subject: [PATCH 06/34] WIP --- finat/functional.py | 81 +++++++++++++++++++++++++++++++-------------- finat/zany.py | 26 ++++++++++----- 2 files changed, 74 insertions(+), 33 deletions(-) diff --git a/finat/functional.py b/finat/functional.py index d5302fe7e..0ab1db044 100644 --- a/finat/functional.py +++ b/finat/functional.py @@ -1,4 +1,4 @@ -"""Symbolic representation of degrees of freedom. +r"""Symbolic representation of degrees of freedom. A :class:`Functional` represents a degree of freedom in the form @@ -22,11 +22,19 @@ Brubeck & Kirby (2025). """ +from math import factorial, prod + import numpy from FIAT.finite_element import FiniteElement +from FIAT.polynomial_set import mis from FIAT.functional import Functional as FIATFunctional -from gem import Literal, Node +from gem import Node, Zero + + +def multiindices(sd: int, order: int) -> list: + """Multi-indices of a given order, with axis ordering for order 1.""" + return sorted(mis(sd, order), reverse=True) class Functional: @@ -43,6 +51,7 @@ class Functional: direction : For ``order > 0``, the direction tensor of rank ``order``, either numeric or a GEM expression; ``None`` for ``order == 0``. + """ def __init__(self, points: tuple, weights: numpy.ndarray, @@ -73,6 +82,7 @@ def from_fiat(cls, node: FIATFunctional, tol: float = 1e-12) -> "Functional": ------- Functional The symbolic representation of the FIAT functional. + """ if node.pt_dict and node.deriv_dict: raise NotImplementedError( @@ -91,19 +101,17 @@ def from_fiat(cls, node: FIATFunctional, tol: float = 1e-12) -> "Functional": sd = node.ref_el.get_spatial_dimension() order = node.max_deriv_order - if order != 1: - raise NotImplementedError( - f"{type(node).__name__} has derivative order {order}.") + alphas = multiindices(sd, order) + lookup = {alpha: k for k, alpha in enumerate(alphas)} points = tuple(node.deriv_dict) - W = numpy.zeros((len(points), sd)) + W = numpy.zeros((len(points), len(alphas))) for q, pt in enumerate(points): for w, alpha, comp in node.deriv_dict[pt]: if comp != tuple(): raise NotImplementedError( f"{type(node).__name__} has vector components.") - k, = numpy.flatnonzero(alpha) - W[q, k] += w + W[q, lookup[tuple(alpha)]] += w # Factor the weights as a common direction times scalar weights u, s, vt = numpy.linalg.svd(W) @@ -112,7 +120,7 @@ def from_fiat(cls, node: FIATFunctional, tol: float = 1e-12) -> "Functional": f"{type(node).__name__} has no common derivative direction.") direction = vt[0] weights = u[:, 0] * s[0] - return cls(points, weights, order=1, direction=direction) + return cls(points, weights, order=order, direction=direction) def with_direction(self, direction) -> "Functional": """Return the same functional with another direction tensor.""" @@ -120,7 +128,7 @@ def with_direction(self, direction) -> "Functional": order=self.order, direction=direction) def pullback(self, J: Node) -> "Functional": - """View this reference functional as acting on physical functions. + r"""View this reference functional as acting on physical functions. By the chain rule, reference derivatives of a pullback are physical derivatives contracted with the Jacobian, so the @@ -138,17 +146,36 @@ def pullback(self, J: Node) -> "Functional": The functional with direction :math:`J \\otimes \\dots \\otimes J : D`, acting on physical derivatives at the images of the reference points. + """ if self.order == 0: return self - elif self.order == 1: - return self.with_direction(J @ Literal(self.direction)) - else: - raise NotImplementedError( - f"Pullback of derivative order {self.order} not implemented.") + sd = J.shape[0] + alphas = multiindices(sd, self.order) + lookup = {alpha: k for k, alpha in enumerate(alphas)} + + # Distribute the multi-index coefficients over a symmetric tensor + T = numpy.zeros((sd,) * self.order) + for index in numpy.ndindex(T.shape): + alpha = _index_alpha(index, sd) + scale = prod(map(factorial, alpha)) / factorial(self.order) + T[index] = self.direction[lookup[alpha]] * scale + + # Contract each slot with the Jacobian + Jnp = numpy.array([[J[i, k] for k in range(sd)] for i in range(sd)], + dtype=object) + for _ in range(self.order): + T = numpy.tensordot(T, Jnp, axes=(0, 1)) + + # Collapse back onto multi-index coefficients + direction = numpy.full(len(alphas), Zero(), dtype=object) + for index in numpy.ndindex(T.shape): + k = lookup[_index_alpha(index, sd)] + direction[k] = direction[k] + T[index] + return self.with_direction(direction) def evaluate(self, fiat_element: FiniteElement) -> numpy.ndarray: - """Apply this functional to the nodal basis of a FIAT element. + r"""Apply this functional to the nodal basis of a FIAT element. This is the generalized Vandermonde computation: the restriction of a functional :math:`\\ell` to the polynomial space satisfies @@ -164,14 +191,20 @@ def evaluate(self, fiat_element: FiniteElement) -> numpy.ndarray: ------- numpy.ndarray The vector of values of this functional on the nodal basis. + """ sd = fiat_element.get_reference_element().get_spatial_dimension() tab = fiat_element.tabulate(self.order, self.points) - row = 0 - for index in numpy.ndindex((sd,) * self.order): - alpha = [0] * sd - for k in index: - alpha[k] += 1 - coef = self.direction[index] if self.order else 1 - row = row + coef * (tab[tuple(alpha)] @ self.weights) - return row + if self.order == 0: + return tab[(0,) * sd] @ self.weights + alphas = multiindices(sd, self.order) + return self.direction @ numpy.array([tab[alpha] @ self.weights + for alpha in alphas]) + + +def _index_alpha(index: tuple, sd: int) -> tuple: + """Convert a tensor index into a derivative multi-index.""" + alpha = [0] * sd + for k in index: + alpha[k] += 1 + return tuple(alpha) diff --git a/finat/zany.py b/finat/zany.py index 24317ac9b..0cf841f46 100644 --- a/finat/zany.py +++ b/finat/zany.py @@ -1,4 +1,4 @@ -"""Automatic basis transformations for physically mapped elements. +r"""Automatic basis transformations for physically mapped elements. This module automates the transformation theory of Kirby (2017) and Brubeck & Kirby (2025). Given a FIAT element whose degrees of freedom @@ -33,7 +33,7 @@ def generalized_cross(tangents) -> numpy.ndarray: - """Generalized cross product of d-1 vectors in d dimensions. + r"""Generalized cross product of d-1 vectors in d dimensions. Parameters ---------- @@ -46,6 +46,7 @@ def generalized_cross(tangents) -> numpy.ndarray: The vector :math:`C` such that :math:`C \\cdot w = \\det([t_1; \\dots; t_{d-1}; w])` for all :math:`w`; it is orthogonal to every :math:`t_k`. + """ A = numpy.asarray(tangents) d = A.shape[1] @@ -59,7 +60,7 @@ def generalized_cross(tangents) -> numpy.ndarray: class FacetFrame: - """Normal/tangential frame of a facet and its push-forward. + r"""Normal/tangential frame of a facet and its push-forward. The reference frame consists of the FIAT facet normal :math:`\\hat{n}` and the scaled facet tangents :math:`\\hat{t}_k`; @@ -79,6 +80,7 @@ class FacetFrame: The facet number. J : GEM expression for the cell Jacobian. + """ def __init__(self, fiat_element: FiniteElement, entity: int, J: Node): @@ -105,7 +107,7 @@ def __init__(self, fiat_element: FiniteElement, entity: int, J: Node): self.normal_scale = normC / kappa def reference_coefficients(self, direction: numpy.ndarray) -> numpy.ndarray: - """Expand a numeric direction in the reference frame. + r"""Expand a numeric direction in the reference frame. Parameters ---------- @@ -117,12 +119,13 @@ def reference_coefficients(self, direction: numpy.ndarray) -> numpy.ndarray: numpy.ndarray Coefficients ``(a, b_1, ..., b_{d-1})`` such that the direction equals :math:`a\\hat{n} + \\sum_k b_k \\hat{t}_k`. + """ A = numpy.column_stack([self.normal, *self.tangents]) return numpy.linalg.solve(A, direction) def decompose(self, direction: Node) -> list: - """Expand a GEM direction in the un-normalized physical frame. + r"""Expand a GEM direction in the un-normalized physical frame. Parameters ---------- @@ -134,6 +137,7 @@ def decompose(self, direction: Node) -> list: list GEM coefficients ``(x_0, x_1, ..., x_{d-1})`` such that the direction equals :math:`x_0 C + \\sum_k x_k J\\hat{t}_k`. + """ sd = self._adjA.shape[0] return [reduce(add, (self._adjA[m, i] * direction[i] @@ -151,7 +155,7 @@ def _weight_ratio(wi: numpy.ndarray, wj: numpy.ndarray, tol: float) -> float: def _conditioning_scaling(V: numpy.ndarray, fiat_element: FiniteElement, coordinate_mapping: PhysicalGeometry) -> None: - """Rescale derivative degrees of freedom by the cell size. + r"""Rescale derivative degrees of freedom by the cell size. Each physical node of derivative order :math:`m` is redefined with a factor :math:`h^{-m}`, where :math:`h` averages the cell size over @@ -167,6 +171,7 @@ def _conditioning_scaling(V: numpy.ndarray, fiat_element: FiniteElement, The FIAT element. coordinate_mapping : Object providing the physical geometry as GEM expressions. + """ # cell_size may be a GEM expression or a numpy array of numbers h = coordinate_mapping.cell_size() @@ -186,7 +191,7 @@ def _conditioning_scaling(V: numpy.ndarray, fiat_element: FiniteElement, def zany_basis_transformation(fiat_element: FiniteElement, coordinate_mapping: PhysicalGeometry, tol: float = 1e-12) -> ListTensor: - """Compute the basis transformation matrix of a FIAT element. + r"""Compute the basis transformation matrix of a FIAT element. Parameters ---------- @@ -202,6 +207,7 @@ def zany_basis_transformation(fiat_element: FiniteElement, gem.ListTensor The transformation :math:`M = V^T` mapping pulled-back reference basis functions to physical nodal basis functions. + """ ref_el = fiat_element.get_reference_element() sd = ref_el.get_spatial_dimension() @@ -233,7 +239,7 @@ def zany_basis_transformation(fiat_element: FiniteElement, def _facet_rows(V: numpy.ndarray, group: dict, fiat_element: FiniteElement, entity: int, J: Node, processed: set, tol: float) -> None: - """Assemble the rows of V for derivative nodes on a facet. + r"""Assemble the rows of V for derivative nodes on a facet. Physical facet nodes take their normal component along the physical facet normal and their tangential components along the mapped @@ -259,6 +265,7 @@ def _facet_rows(V: numpy.ndarray, group: dict, fiat_element: FiniteElement, Indices of the already assembled rows; updated in place. tol : Tolerance for detecting zeros in the numeric coefficients. + """ frame = FacetFrame(fiat_element, entity, J) for i, ell in group.items(): @@ -290,7 +297,7 @@ def _facet_rows(V: numpy.ndarray, group: dict, fiat_element: FiniteElement, def _point_jet_rows(V: numpy.ndarray, group: dict, J: Node, processed: set, tol: float) -> None: - """Assemble the rows of V for derivative nodes away from facets. + r"""Assemble the rows of V for derivative nodes away from facets. Away from facets there is no geometric frame, and physical nodes keep the reference (Cartesian) directions, so the group must span @@ -311,6 +318,7 @@ def _point_jet_rows(V: numpy.ndarray, group: dict, J: Node, Indices of the already assembled rows; updated in place. tol : Tolerance for detecting zeros in the numeric coefficients. + """ directions = numpy.array([ell.direction for ell in group.values()]) if len(set(ell.points for ell in group.values())) > 1: From 71591fdcbabf82a61024e4ec735f5d866c5fa060 Mon Sep 17 00:00:00 2001 From: Pablo Brubeck Date: Mon, 13 Jul 2026 15:11:13 +0100 Subject: [PATCH 07/34] Reimplement Argyris and Bell on the automatic framework Generalize Functional to arbitrary derivative order: directions live in derivative multi-index space, and pullback distributes them over a symmetric tensor, contracts each slot with the Jacobian, and collapses back. Point-jet groups are split per order so that Argyris/Bell vertex gradients and Hessians each solve in their own direction basis. Facet completions now recurse through vertex-jet rows and same-edge trace moments with no new code. zany_basis_transformation gains two options: avg=False divides facet-moment columns by the physical facet measure (the legacy convention where physical edge moments are plain integrals), and ndof drops trailing columns so constrained elements (Bell) can discard the constraint dofs of their extended element. The automatic matrices match the deleted hand-coded ones to machine precision for Argyris degrees 5-7 (integral variant with both avg conventions, and the point variant) and Bell. One convention change: the generic h^-m conditioning scaling now also applies to integral Argyris edge moments, which the hand-written code left unscaled; this is invisible when cell_size == 1. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 34 +++++++++++--- finat/argyris.py | 73 ++++++------------------------ finat/bell.py | 71 ++++++++--------------------- finat/functional.py | 6 +-- finat/zany.py | 61 +++++++++++++++++-------- test/finat/test_zany_automation.py | 4 +- 6 files changed, 105 insertions(+), 144 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8292690d9..7655c6b41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -160,7 +160,8 @@ the theory factor by factor, not merely reproduce matrix entries: **Status (2026-07-13).** The framework lives in `finat/functional.py` (the symbolic `finat.Functional`) and `finat/zany.py` (`FacetFrame`, `zany_basis_transformation`). -`finat.Morley` and `finat.Hermite` are reimplemented on it: `basis_transformation` is +`finat.Morley`, `finat.Hermite`, `finat.Argyris` (both variants, degrees 5-7 tested, +with the `avg` convention flag), and `finat.Bell` are reimplemented on it: `basis_transformation` is one call to `zany_basis_transformation(self._element, coordinate_mapping)`, working in 2D and 3D through a single dimension-independent code path, verified by `check_zany_mapping` and matching the previous hand-coded matrices (including the @@ -229,11 +230,32 @@ Key facts the framework rests on: (GEM overloads `+ - * / ** @` with `Zero`/constant folding; keep numpy object arrays on the left when scaling by a GEM scalar). -Next steps: second and higher derivative orders (pullback = tensor powers of $J$; -symmetric direction tensors in `Functional`) to cover Bell/Argyris vertex jets; -facet-derivative completion rows coupling to jet dofs (the recursion already supports -it once jets have rows); the extended-element path for reduced HCT/Bell; vector-valued -components for Piola-mapped elements. +Extensions beyond first order and Morley/Hermite: + +* `Functional` directions live in derivative multi-index space (`multiindices`, axis + order for $m=1$); `pullback` distributes them over a symmetric tensor (dividing by + multiplicities), contracts every slot with $J$ (`numpy.tensordot` on object arrays), + and collapses back. Point-jet groups are split per order; each order solves in its + own multi-index direction basis (Argyris/Bell vertex jets: gradient + Hessian). +* Facet completions of Argyris edge moments couple to vertex jets and same-edge trace + moments; the existing row recursion handles both with no new code (trace moments are + order-0 and thus invariant; FIAT builds all these moments with + `FacetQuadratureRule(avg=True)`, i.e. measure-intrinsic, as the framework assumes). +* `zany_basis_transformation(avg=False)` reproduces the legacy FInAT convention where + physical facet moments are plain integrals: their columns are divided by the + physical facet measure $\|C\| |\hat e| / \|\hat C\|$ (`FacetFrame.measure`). + Single-point facet dofs (Argyris "point" variant) are unaffected. +* Bell is the extended-element pattern: FIAT.Bell is the 21-node quintic element with + the constraint functionals as extra edge nodes; `ndof=18` drops the constraint + *columns* of $V$ (their rows still contribute the $D$-matrix entries through the + completion recursion), and the FInAT element overrides `entity_dofs`. +* Known convention change: the generic $h^{-m}$ conditioning scaling now also applies + to integral-variant Argyris edge moments, which the hand-written code left unscaled + (Morley scaled them; the legacy convention was inconsistent). Invisible when + `cell_size == 1`; flag in PR review. + +Next steps: the extended-element path for reduced HCT (macro polynomial spaces); +vector-valued components for Piola-mapped elements. The implementation mirrors the theory factor by factor: diff --git a/finat/argyris.py b/finat/argyris.py index e907eff83..555e68847 100644 --- a/finat/argyris.py +++ b/finat/argyris.py @@ -8,7 +8,9 @@ from finat.citations import cite from finat.fiat_elements import ScalarFiatElement -from finat.physically_mapped import identity, PhysicallyMappedElement +from finat.physically_mapped import (identity, PhysicalGeometry, + PhysicallyMappedElement) +from finat.zany import zany_basis_transformation def _jet_transform(J, order): @@ -128,70 +130,21 @@ def _edge_transform(V, vorder, eorder, fiat_cell, coordinate_mapping, avg=False) class Argyris(PhysicallyMappedElement, ScalarFiatElement): + """The Argyris element. + + The basis transformation is derived automatically from the FIAT + dual basis by :func:`finat.zany.zany_basis_transformation`. + """ def __init__(self, cell, degree=5, variant=None, avg=False): cite("Argyris1968") if variant is None: variant = "integral" if variant == "point" and degree != 5: raise NotImplementedError("Degree must be 5 for 'point' variant of Argyris") - fiat_element = FIAT.Argyris(cell, degree, variant=variant) self.variant = variant self.avg = avg - super().__init__(fiat_element) - - def basis_transformation(self, coordinate_mapping): - sd = self.cell.get_spatial_dimension() - top = self.cell.get_topology() - - V = identity(self.space_dimension()) - - vorder = 2 - voffset = comb(sd + vorder, vorder) - eorder = self.degree - 5 - - _vertex_transform(V, vorder, self.cell, coordinate_mapping) - if self.variant == "integral": - _edge_transform(V, vorder, eorder, self.cell, coordinate_mapping, avg=self.avg) - else: - bary, = self.cell.make_points(sd, 0, sd+1) - J = coordinate_mapping.jacobian_at(bary) - detJ = coordinate_mapping.detJ_at(bary) - pel = coordinate_mapping.physical_edge_lengths() - for e in sorted(top[1]): - s = len(top[0]) * voffset + e * (eorder+1) - v0id, v1id = (v * voffset for v in top[1][e]) - Bnn, Bnt, Jt = _normal_tangential_transform(self.cell, J, detJ, e) - - # edge midpoint normal derivative - V[s, s] = Bnn * pel[e] - - # vertex points - V[s, v1id] = 15/8 * Bnt - V[s, v0id] = -V[s, v1id] - - # vertex derivatives - for i in range(sd): - V[s, v1id+1+i] = -7/16 * Bnt * Jt[i] - V[s, v0id+1+i] = V[s, v1id+1+i] - - # second derivatives - tau = [Jt[0]*Jt[0], 2*Jt[0]*Jt[1], Jt[1]*Jt[1]] - for i in range(len(tau)): - V[s, v1id+3+i] = 1/32 * Bnt * tau[i] - V[s, v0id+3+i] = -V[s, v1id+3+i] - - # Patch up conditioning - h = coordinate_mapping.cell_size() - for v in sorted(top[0]): - s = voffset*v + 1 - V[:, s:s+sd] *= 1 / h[v] - V[:, s+sd:voffset*(v+1)] *= 1 / (h[v]*h[v]) - - if self.variant == "point": - eoffset = 2 * eorder + 1 - for e in sorted(top[1]): - v0, v1 = top[1][e] - s = len(top[0]) * voffset + e * eoffset - V[:, s:s+eorder+1] *= 2 / (h[v0] + h[v1]) - - return ListTensor(V.T) + super().__init__(FIAT.Argyris(cell, degree, variant=variant)) + + def basis_transformation(self, coordinate_mapping: PhysicalGeometry) -> ListTensor: + return zany_basis_transformation(self._element, coordinate_mapping, + avg=self.avg) diff --git a/finat/bell.py b/finat/bell.py index d174fe168..00d38924b 100644 --- a/finat/bell.py +++ b/finat/bell.py @@ -1,15 +1,24 @@ import FIAT -from math import comb +from copy import deepcopy + from gem import ListTensor from finat.citations import cite from finat.fiat_elements import ScalarFiatElement -from finat.physically_mapped import identity, PhysicallyMappedElement -from finat.argyris import _vertex_transform, _normal_tangential_transform -from copy import deepcopy +from finat.physically_mapped import PhysicalGeometry, PhysicallyMappedElement +from finat.zany import zany_basis_transformation class Bell(PhysicallyMappedElement, ScalarFiatElement): + """The Bell element. + + FIAT provides the extended element on the full quintic space, with + the cubic normal derivative constraints appended as extra edge + nodes; the transformation of the extended element is derived + automatically by :func:`finat.zany.zany_basis_transformation`, and + the constraint degrees of freedom are dropped from the physical + element. + """ def __init__(self, cell, degree=5): cite("Bell1969") super().__init__(FIAT.Bell(cell, degree=degree)) @@ -20,56 +29,12 @@ def __init__(self, cell, degree=5): reduced_dofs[sd-1][entity] = [] self._entity_dofs = reduced_dofs - def basis_transformation(self, coordinate_mapping): - # Jacobian at barycenter - sd = self.cell.get_spatial_dimension() - top = self.cell.get_topology() - bary, = self.cell.make_points(sd, 0, sd+1) - J = coordinate_mapping.jacobian_at(bary) - detJ = coordinate_mapping.detJ_at(bary) - - numbf = self._element.space_dimension() - ndof = self.space_dimension() - # rectangular to toss out the constraint dofs - V = identity(numbf, ndof) - - vorder = 2 - _vertex_transform(V, vorder, self.cell, coordinate_mapping) - - voffset = comb(sd + vorder, vorder) - for e in sorted(top[1]): - s = len(top[0]) * voffset + e - v0id, v1id = (v * voffset for v in top[1][e]) - Bnn, Bnt, Jt = _normal_tangential_transform(self.cell, J, detJ, e) - - # vertex points - V[s, v1id] = 1/21 * Bnt - V[s, v0id] = -V[s, v1id] - - # vertex derivatives - for i in range(sd): - V[s, v1id+1+i] = -1/42 * Bnt * Jt[i] - V[s, v0id+1+i] = V[s, v1id+1+i] - - # second derivatives - tau = [Jt[0]*Jt[0], 2*Jt[0]*Jt[1], Jt[1]*Jt[1]] - for i in range(len(tau)): - V[s, v1id+3+i] = 1/252 * Bnt * tau[i] - V[s, v0id+3+i] = -V[s, v1id+3+i] - - # Patch up conditioning - h = coordinate_mapping.cell_size() - for v in sorted(top[0]): - s = voffset * v + 1 - V[:, s:s+sd] *= 1/h[v] - V[:, s+sd:voffset*(v+1)] *= 1/(h[v]*h[v]) - - return ListTensor(V.T) + def basis_transformation(self, coordinate_mapping: PhysicalGeometry) -> ListTensor: + return zany_basis_transformation(self._element, coordinate_mapping, + ndof=self.space_dimension()) - # This wipes out the edge dofs. FIAT gives a 21 DOF element - # because we need some extra functions to help with transforming - # under the edge constraint. However, we only have an 18 DOF - # element. + # The extended FIAT element has 21 basis functions, but the Bell + # element only keeps the 18 vertex degrees of freedom. def entity_dofs(self): return self._entity_dofs diff --git a/finat/functional.py b/finat/functional.py index 0ab1db044..46d0b8d0b 100644 --- a/finat/functional.py +++ b/finat/functional.py @@ -92,11 +92,11 @@ def from_fiat(cls, node: FIATFunctional, tol: float = 1e-12) -> "Functional": points = tuple(node.pt_dict) weights = [] for pt in points: - (w, comp), = node.pt_dict[pt] - if comp != tuple(): + wc_list = node.pt_dict[pt] + if len(wc_list) != 1 or wc_list[0][1] != tuple(): raise NotImplementedError( f"{type(node).__name__} has vector components.") - weights.append(w) + weights.append(wc_list[0][0]) return cls(points, numpy.asarray(weights)) sd = node.ref_el.get_spatial_dimension() diff --git a/finat/zany.py b/finat/zany.py index 0cf841f46..0a30d04e2 100644 --- a/finat/zany.py +++ b/finat/zany.py @@ -105,6 +105,8 @@ def __init__(self, fiat_element: FiniteElement, entity: int, J: Node): normC = Power(reduce(add, (C[i] * C[i] for i in range(sd))), Literal(0.5)) self.normal_scale = normC / kappa + vol = ref_el.volume_of_subcomplex(sd - 1, entity) + self.measure = normC * (vol / numpy.linalg.norm(Chat)) def reference_coefficients(self, direction: numpy.ndarray) -> numpy.ndarray: r"""Expand a numeric direction in the reference frame. @@ -190,7 +192,8 @@ def _conditioning_scaling(V: numpy.ndarray, fiat_element: FiniteElement, def zany_basis_transformation(fiat_element: FiniteElement, coordinate_mapping: PhysicalGeometry, - tol: float = 1e-12) -> ListTensor: + tol: float = 1e-12, avg: bool = True, + ndof: int = None) -> ListTensor: r"""Compute the basis transformation matrix of a FIAT element. Parameters @@ -201,6 +204,14 @@ def zany_basis_transformation(fiat_element: FiniteElement, Object providing the physical geometry as GEM expressions. tol : Tolerance for detecting zeros in the numeric coefficients. + avg : + If False, physical facet moments are plain integrals rather than + the measure-intrinsic integral averages of the reference nodes, + and their columns are rescaled by the physical facet measure. + ndof : + Optional number of physical degrees of freedom; trailing columns + are discarded, so that constrained elements can drop the basis + functions of their extended element. Returns ------- @@ -229,16 +240,18 @@ def zany_basis_transformation(fiat_element: FiniteElement, if not group: continue if dim == sd - 1: - _facet_rows(V, group, fiat_element, entity, J, processed, tol) + _facet_rows(V, group, fiat_element, entity, J, processed, + tol, avg) else: _point_jet_rows(V, group, J, processed, tol) _conditioning_scaling(V, fiat_element, coordinate_mapping) - return ListTensor(V.T) + return ListTensor(V[:, :ndof].T) def _facet_rows(V: numpy.ndarray, group: dict, fiat_element: FiniteElement, - entity: int, J: Node, processed: set, tol: float) -> None: + entity: int, J: Node, processed: set, tol: float, + avg: bool = True) -> None: r"""Assemble the rows of V for derivative nodes on a facet. Physical facet nodes take their normal component along the physical @@ -279,6 +292,9 @@ def _facet_rows(V: numpy.ndarray, group: dict, fiat_element: FiniteElement, # Expand the pulled-back node in the physical frame x = frame.decompose(ell.pullback(J).direction) c = x[0] * frame.normal_scale / a + if not avg and len(ell.points) > 1: + # the physical moment is a plain integral, not an average + c = c / frame.measure row = numpy.full(V.shape[1], Zero(), dtype=object) row[i] = c for k, that in enumerate(frame.tangents): @@ -320,20 +336,25 @@ def _point_jet_rows(V: numpy.ndarray, group: dict, J: Node, Tolerance for detecting zeros in the numeric coefficients. """ - directions = numpy.array([ell.direction for ell in group.values()]) - if len(set(ell.points for ell in group.values())) > 1: - raise NotImplementedError("Group nodes at different points.") - if directions.shape[0] != directions.shape[1]: - raise NotImplementedError( - "Directions do not span the derivative jet.") - - # coefficients of the direction basis expansion of each Cartesian axis - Dinv = numpy.linalg.inv(directions.T) + suborders = {} for i, ell in group.items(): - Jd = ell.pullback(J).direction - for col, (j, ellj) in enumerate(group.items()): - s = _weight_ratio(ell.weights, ellj.weights, tol) - x = s * Dinv[col] - nz = numpy.flatnonzero(abs(x) > tol) - V[i, j] = reduce(add, (Jd[m] * x[m] for m in nz)) if len(nz) else Zero() - processed.add(i) + suborders.setdefault(ell.order, {})[i] = ell + + for sub in suborders.values(): + directions = numpy.array([ell.direction for ell in sub.values()]) + if len(set(ell.points for ell in sub.values())) > 1: + raise NotImplementedError("Group nodes at different points.") + if directions.shape[0] != directions.shape[1]: + raise NotImplementedError( + "Directions do not span the derivative jet.") + + # coefficients of the direction basis expansion of each multi-index + Dinv = numpy.linalg.inv(directions.T) + for i, ell in sub.items(): + Jd = ell.pullback(J).direction + for col, (j, ellj) in enumerate(sub.items()): + s = _weight_ratio(ell.weights, ellj.weights, tol) + x = s * Dinv[col] + nz = numpy.flatnonzero(abs(x) > tol) + V[i, j] = reduce(add, (Jd[m] * x[m] for m in nz)) if len(nz) else Zero() + processed.add(i) diff --git a/test/finat/test_zany_automation.py b/test/finat/test_zany_automation.py index e44aeaa25..ee7816dc5 100644 --- a/test/finat/test_zany_automation.py +++ b/test/finat/test_zany_automation.py @@ -54,8 +54,8 @@ def test_conditioning_scaling(ref_to_phys, scaled_ref_to_phys, element, dimensio def test_unsupported_nodes(ref_to_phys): - """Second derivative nodes are not handled yet.""" + """Vector-valued nodes are not handled yet.""" mapping = ref_to_phys[2] - element = FIAT.Bell(mapping.ref_cell) + element = FIAT.RaviartThomas(mapping.ref_cell, 1) with pytest.raises(NotImplementedError): zany_basis_transformation(element, mapping) From 3462bec356d2faa3bfa853c727957e0016d4cfaa Mon Sep 17 00:00:00 2001 From: Pablo Brubeck Date: Mon, 13 Jul 2026 15:45:35 +0100 Subject: [PATCH 08/34] Automate Piola-mapped transformations: Mardal-Tai-Winther, Johnson-Mercier Extend the symbolic Functional with a value rank, parsing component weight profiles from pt_dict. Under the contravariant Piola map the scalar roles are mirrored: scaled facet normals are cofactor images K = adj(J)^T of the reference ones (exactly the physical compute_scaled_normal), so pure normal moments are invariant, while scaled tangents map by J. _piola_facet_rows matches per-point frame-coordinate profiles within each facet group (covering 3D MTW's point-varying RT-mapped tangential directions) and eliminates the residual normal profile through the Vandermonde recursion. FIAT builds 3D tangential components on the reciprocal basis cross(n, t_k), which transforms in-plane contravariantly; the tangent Gram change S is absorbed into the coordinate mixing (S = 1 in 2D). Interior moments are Piola-invariant by construction. Double contravariant tensor elements reuse the same code with one contraction per value slot. MardalTaiWinther (2D/3D, orders 1-2) and JohnsonMercier (2D/3D) now derive their transformations automatically, matching the deleted hand code to machine precision. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 27 +++++- finat/.functional.py.swp | Bin 0 -> 28672 bytes finat/.zany.py.swp | Bin 0 -> 28672 bytes finat/functional.py | 29 ++++-- finat/johnson_mercier.py | 29 +++--- finat/mtw.py | 36 ++----- finat/zany.py | 147 +++++++++++++++++++++++++++++ test/finat/test_zany_automation.py | 4 +- 8 files changed, 214 insertions(+), 58 deletions(-) create mode 100644 finat/.functional.py.swp create mode 100644 finat/.zany.py.swp diff --git a/AGENTS.md b/AGENTS.md index 7655c6b41..593e59e0b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -254,8 +254,31 @@ Extensions beyond first order and Morley/Hermite: (Morley scaled them; the legacy convention was inconsistent). Invisible when `cell_size == 1`; flag in PR review. -Next steps: the extended-element path for reduced HCT (macro polynomial spaces); -vector-valued components for Piola-mapped elements. +**Piola-mapped elements** (Aznaran, Kirby & Farrell 2022). `Functional` carries a +value rank: component weight profiles (nq x sd^rank) parsed from `pt_dict` component +tuples. Under contravariant Piola the roles of the scalar case are mirrored: the +*scaled* facet normal is the cofactor image $K\hat n_s$, $K = \mathrm{adj}(J)^T$ +(exactly the physical `compute_scaled_normal`, cross product of mapped tangents), so +pure normal moments are invariant, while scaled tangents map by $J$. `_piola_facet_rows` +works with per-point *frame-coordinate profiles* (handles 3D MTW's point-varying +RT-mapped tangential directions): the pulled-back profile is contracted per value slot +with the mixing matrix $Y$; tangential profiles are matched within the facet group by a +numeric pseudo-inverse; the residual normal profile is eliminated by per-point normal +moments through the Vandermonde recursion (this is where e.g. tangential-to-normal +couplings emerge). Key subtlety (in any dimension > 2): FIAT builds tangential value +components on the **reciprocal basis** (`cross(n, t_k)`), which transforms in-plane +contravariantly: absorb $S^{-1} = (\det\hat G_t/\det G_t)\hat G_t^{-1} G_t$ +(tangent Gram change) into $Y$'s tangential rows; in 2D $S = 1$, which hides the effect. +Interior value moments are Piola-invariant by construction (scaled-normal components for +JM; covariant Nedelec test fields for MTW order 2 cancel the contravariant trial +exactly). Double contravariant (tensor) elements use the same code: one contraction per +value slot. MTW (2D/3D) and Johnson-Mercier (2D/3D) are reimplemented this way, matching +the deleted hand code to machine precision; RaviartThomas-type elements come out as pure +identity (Piola-equivalent) automatically. + +Next steps: Guzman-Neilan (PiolaBubbleElement pattern: vertex point values map by $K$ +like a value point-group, facet bubbles + constraint columns); the extended-element path +for reduced HCT (macro polynomial spaces); covariant elements. The implementation mirrors the theory factor by factor: diff --git a/finat/.functional.py.swp b/finat/.functional.py.swp new file mode 100644 index 0000000000000000000000000000000000000000..5aff1b21999ec7ea0fb13a9adacee91a3102d4f3 GIT binary patch literal 28672 zcmeI4TZ|;vS;xy4NDPFab(HWBoa5O9y6v9coiz?gJG1iIyX$zs3tEQVC^M6un(peE znyIepsj8mK+6yl{I2a3ffLJ04A_`G1QhtFer+{B>@yJB6bw6QV=Y^ z?_8>?duG>5041`jr2p1*)w%x9cfNC}|Jk+U7ao2@y|F##aebHPz3Gn@H$QV`=BAmy zd9}Bej@N?T*3eJ$P+ni_%2R(dyXo(?hkm}1wUfQJ-)oPz4g^1z^-@3YZ5*p6d~6V= zK|l4kg4vxQ-Z_?~Js$Zv|HSg|IPT?P68lkmw0|JQ`a#V=&A<&YFwO_FZ@A67=fvD` z34e6vPIdc{AG@Kbb)cGont_^unt_^unt_^unt_^unt>Mw1NrC{?>Vt;?TyTEu`xLkWejMBf{*8tGUxLqrXTit7)8K>P1K?3` z5!?Z80XKu2z%$?Gd5?p)fQ#S)I1T>mHqZM{@I3eecm{kByc3K;2u^`H@cc_X?^nQk z!8^e(f)(&;aP=jg_eF38ya&7;JPvlj>%d>Laq(B+m%&@W?clj@@w_jCXThhy)8H4t zFh3jKMkJfvaqMd;$C>*aQ!OS@2ahWZnlZfn(rnw2!pW zC%|8TKL=NC@>J#84Z`(}JX7;39uG(R?Mp$LWQ|4?#EnrB#(CCksjPq3U8x^$HJcMr zv%bVi`awGl2HhZPuLpTI8~J(YN8Nrn4B`w8Mq@J4QMKbo<3O2w)hJB{VH9*!5(g?x zc2zP^y<|AzXPnzWnHmMD(xsWmUuuyC`8bVLFUo9jTQ1UMwi5Q5*C0vNZcFE@!q`-4 zlv9OXzBI~LRt_dL&6=P2Y3lDcmUfjSHp(R(!=G2pHXJA+Qq9k+*3Y2)QTgLQKKj9RYH@iYJ5x3i%g3WAXy{h7RW55wY2zd}eDs4f+|iVDyor>g zdV`rq^Zk)YrrGXxV}BTQyO-54+zT?>;C|d!IuYz+$F+afo+-1Flu|#;0(CCQA0ARJ zOehcfXVNrD8=kzDOP30xv&B`Cur7bKh$g5~Gcz-j#a-M8RJK1{OQNuc^^DRWBNNJ$ z#A16nug^YwYEe}rM4n6RY#}Qu9?hDo_X%RA=NG-NZRLv$dHmJQGg-z0^OKW z7BP!i=^F#TmnUg>sanz^5#p&ybCPOavmU}$CA_Tzw<5tG@l%>G57J6a&}pUNo{)6C z`t-xWAV{GUHI{Jdr&J>7B|AK%oyJ4#sOLw~zFONaDiy}-!hDgv^gNMorD2eDbemMN zOj3U6Ru_V#rwo(0sro3wbSz3(BqTKSk~quLv3(bla~NkM!e%;U-4k?jWgcboVLuPL z_{uLWr!)mMNRuJ24rRL^4D|c1h|?N)sg^d?jt*U>(NeTvM-8IH&*_tM!R*|LrkXve zX38)#rh)J8MShmiHXBL5EM^u&gSK@caw`bXFG_0WOPVHw7*gkzzE8d?eVzSl@^#@! z4smx%E}dit(xqz0TV)C6&tcdtZQ{l2rm3ay*6oIIn0LDksYA;N8R^jD4cG!yh z+5xu=B@?|q2(gpYnI6TVu^u|}hzjt?XNl_G zos?HJ8}?PQn=|4Oh907u!iFWBPNED~c2%*@((DXXsw0ZX&g#%+Z3%iY%)pmTgHBY04=~I|La1QRhYb!Fn3##0T8_ z^oCt1m)=6ZCf7;@3l7?zwqsS=H^Yji$&Tm{iR*6+!>p0@TZS2?B58ml$%6UCbR3wG zgOWTlj`DCejBy(bH_-2^b;cMRy0{8#MHgo;x>%{-k z7IuL3Lfo!3G#R+9rD}dnvcp71{fySgaLmGn9#K4y{*Vu+oM`@}ZpqPY+eR zO45C)d(m2RKhW&Z>!ere?%=wm^o)4l)X4FR#`aT)aH49p;D|G&` zsfIrDh`{;wgNe|s!x)B>*prEfoqn`5hf=b#oviP+zW4O1YB)DevtwtvjRJ3%p(N1E zIB#hRv)A2T)u|E~T{?@B;Fz&wXX4JS?-J7U+HJ~?m+z?MWjJXJnhcK1%a~|)TM>l6 zBVT*Z(=Gmv{k6z{+fWS}WYpvV#N%2S?oO$W_N1NF(zUFXjfve`OArfjhQQ*)G;TWd zytL}er>uZD2kofs$@>3|tluvKS^u--*4Hzv^=0kKlyIK}9|AuQB;I47 zzG?<)25JUs25JUs25JUs25JUs25JUs25JVb$pBl*tZ(&aKlAa*k477Q`58(7T-sVu zce9hOw-jY-VN1TqvAGxQnf*;2iZ%Dr@fCF!UpRF@R>JJ|vkR27Y_>ZBI*#mmC3-1c z>_zC^LfLc-_KF=ucC7i?)oC|OW^TXQ?kDO!L|tsR-zn9(p-b<-NL8gKI*Y9GWzub% z%7=h*SL1%WJ#N2F=Kk7}j=7@lSNhYy^p?I!7ha||L)o#Cl=TKoQ?LIYW6}RozR}D2 z-|6?CW3B&L@NV!f@Fchl*1>DQ9pDJ~G;9ASz&1D!UI{*j2H@l139tfQ3I2rip8uNq3ka)hw~Ka-8b)^>09KB7|*s}wj9 zfoLMLbBdTYkH!cq11%skOIKTDn{AP-Rx(e-6)AHeTW~2hnlC7E7X3_-tdx2YmB9{T zL$)CJn69e6ZV~ew*?mGA$Lql5n;bn>$PzuUT+&#|_DFL(@`0e6CbVr~Bcun+dY zac~>>66^aVk_fPZJ7LCyhO0sCMN+zb90J^!zPr@%YFPl9a_ zfP26RFawT&tCaKOU>`gJq}=y|*MXOTf2A(J2i^{(UMIm3@FleTzXi6z_k(-Dt>9_& z{67P>!0W-i;3}H^-v&PmR>3T|dMjrRz{kKg7=iBtqTPQAtN|aK1NVW~f-j@n|3~m= z;4|Q(U1chK6_l+9F|xrZyo4(20t&z&MZJw2UCuO& zjSk-^z8O0Z{Aty8=}~d7r$!@GantV8$g`^M)2IQfzR&O@O3y(2up1drja5bsq}f#k zrQWZd@V}Mg54N|NjZ`$I9FMRX^hWX55<9-`hfEM>ACR#c9-FFbI302s+C~l+0y}H<_Ak_I{E# zm{2UlBgAv{9Kyb*(z| z8r_f>llx*4U~YIGri1!X)+`T1nFqwa?ne(-Y&}_AC}y;tdoK5iM<|}VIja$`>XTp9 zC`CAV7!E~IEv`$9re&jkX@!0R7aiWFajnh^Ezgdg$}pW&LRZOCDDoO zWH_zNLABKuw&>jb0t1B2X?dA`TKe@;aayE$LzB|U(-*p>q`LY*g6lBlU`F+(lYgP3 zO1dI;5L~F7;3(^6Kkm_eM0UNyNh3ebqy0{$4{JfT>Dht#JSfZ`N-a`u?Ex6m4)lCn zw8*_ZNl z+G8T~nukhi^F>L@%Dclk^J=`*X({vlnWeGkm@VSqk#_Rx8yDhQ=PlWXkOK|&96@PI zB_Utmc}q?WG_s|+&Wbw8v@ep41&UP}dB-$bi^5W3Gpa}YKkU(ZJKO0uE|yh4s?O?T zjJg9j9>h?KBD)EcGOkU=I-kS2=8$sf?;Q~rm$unWFteko7+^(z{ogGpb>ZFpdIZ|l zx=cv*DdYoZR9zxBa8}g?Wekw?)Sd3YZG}4tBZC!ZclAItbr4y%VsRF^bcGUy%A2Fc z<=|$wHp*}pi#9pzXm@3{SD9>?M2KiOmGh$QvK;37|Jzsx%Q{)s|0nEH^j}!-%O1e* zfcJw-;7#B@@Kx6QSHM#s0Kc@4-jF2s{XG2VZ2}{}J%( zU<(`tGvHa)_&*00z$?J*zyr^*w*M@6H+UC#5?luB;5Fb5a0GmsdOQIVpzCDoQ{QR^ zY6fZsY6fZsY6ku{47iEV^xdrlCO-FEOPqMfO4@S?eiLn27iE&NEwWa5(S84Mq}Myv xFSuhZEA@KIS}$yjBD&tPPNx=st~Z%`y=7f*SzD2RxvKxhZ&`cJO7Umje*@P$xgG!j literal 0 HcmV?d00001 diff --git a/finat/.zany.py.swp b/finat/.zany.py.swp new file mode 100644 index 0000000000000000000000000000000000000000..9403cd2792b5a2eb0b2102b06104eb3b0ed44db3 GIT binary patch literal 28672 zcmeI5e~ctYb;sNI2ZqFegGC6jpuC6h%zAe2_8e^3<1RU$?~YsUj*VF_!Lxg_y)!+# zy*txA*WI%}?tJ2pC{l<}Vk^jh7>tl&2ue(p80CkABP5grg$Ub;gM}6I4^BjWIFSg! z%KcP-BquuUcGu%@71fmm3<2*PN{d+ryZ`>InLiaF~5G> zyC$xfc#b+=U?Y>Xw}8@D1iX|3%o z65iYKBd;B~8{X8W7i{j0qZYT^gg*g)&$+=)y}vU?tGrQipya?y;J_g1Ouge(&aL~W z_erij6E~^X|JdCxfk2tG~$vpD)<^e?0R1JN9|g-v3EkKw8cV_W3>b`MHtjSK0eF+Rwin zd2Vl)A0-D$4wM`yIZ$$-yb*luTE}@1 zoCSTb2Hp+c4qgYY2LJR*$9Wn&06qv>UO2_#g_$;^peimF0zJ}A~S?~x5K>%(B|43g* ze>@_6B9}5(`e}cTeRRS|%_hnZRP1#-L}T>(Ub~sN!KxP|@lrL*e?%Qr-18#0>z^Y@ zD+=Sd(rC;k4Sz`q8DFSGlCu)Gt4mqV-3UXhF{^$?o%9o-SV^jyL|&1s23bqg26c1E zsObe^)N{K~II`v@YOe|gz5Y(UOZ-Q7wJr}U@}N+CNw!-HG_|y}YF=8gl!GerI!&)z zZ-u@7An}@}^a?EUlbyVfAzG9+k}E9*_NXZ}T~lNE+j*u{FKNboH}TzWv+ei1Aojzc zl9y#@lU@Q}sGCQUq%OI5;zeH2^3uCDSKI5TX44P+q}i-URck|eXVfuy<{dSp(%Q|? zt4U4G&ZyI2+pEqP4oyr<&nw%+n(9a4rr-91Rh2*@YppKN5Gk_6lA6jcU4c0_ za(f=K7n^3}RN8Lh8Y*tkRx=WwG_$<8sD*D?xw@1oKWKY>k3T`8T44~=vk5h6g`04` ztvXTIlgjC$w)|u*rE_Fi*SRo`lpaUYruLwfsIa3Gm|>_=fzfpOYdf*uLgt09aZRVo z?%Cns$7xBn#*2$Jb%f!uxOndou&gOrDbDMe6{J>nG~F2HNI&)lU02bi!;FS8n$Acq z)1qe=7a45llMBs_<+@ULcrAAjdrEhrsb{R*$S_9;XN~AcpJ;yiaT2Gz$R8VFF#}&Z zC*^R~Nm6WZfnG8XRSdhNJ9NgBnQ1i(wLGAkGe0CvyH`dx!b+{Kh!r2k!)g6{uPugQ zix^0I=u+2KLF(nxvi=}mn}WStZq%+jt*#r#>X=mWnB-uH>H0)oGKd1J+1%KTB6p{9 zB-a3~up5i+J*eiRfnlfOI#L%M9<(c!sp+bkoKzL9ZPYEw=cj6m)c4SnK~g!~n5n_< zE_!}9vd7$RZ0L@t_cm%k7e(0(qv(YSzl<6V123+$YpR{>^u2>CVc0d~MZF!a$7^oi zYfPJbhqFv+3)6lwS%wr%7$s}rYKV?kNvJ#q+fLe>i3ppnlvHeSk!DsJNpt#ud=HcO zfJ&O@+f&mQ4ydiA>atc+F8ayJ&erk)dPeziMna@v(`zM6Y&H#pHy*Us5Id%l^d96} z&{tdPpw4O-5VUn~={r{O=13mCZYpC$mREZ`t`jtwB{y9kQKo+n!3X zwDOWyK_F|&7nQ2#RINjGJZC-C0NtIYRXKjIk1VLloYjfPy%234?bMXpULUNwiC1$9 z1UiGDl`u6SH#X<7?2bvd>h;n`c1coWZ4xK*%)4P!({o=<&4pVYKko6O&}0*JP^Xei zo9Semk{OyzSp%CIYw>VQ=CIGwN94;sbK7lOX{j88CQhF)rwm%2C$nH*iLSe9c zH;RoZ-_@%UWb9fwC4d!Q}Pv181a#Xe5@iGVP!MBYzWaY*H_`8?ua;G*UHL zSWBdG?br!96~T1HGBgP(E0uZe((Gs*Rskz+YeN*4a3>?AS4VcbZLPVobRKj)J$31Z zw3)cLnX%hx`?4Htt;xbi)){6(&H122*~WC>Q5PkZ*F}0jI-o!3G8sBEdb*ieHbX1V zUeAYw5h0c&Emm|?L-&*{J-YP2D5Y?aICpxhkp~N5P=_yVRy@Mp$dh#-%qCyF(_0C< zCL=2gCzaDv>e!jdik^QL7w2LhbzEE|onlH@I5Ur2XUx)4R8z6=5am0>$Q3uHb!kIP zABQUm9Hk0woEEyUOx?Ql``EhCh-lhBg{e7Rf;iH!041-R7HvXzAo4mikE6TnS}(q_kbI~>%rsL{=W!LfLDO$u>Zdfo&tXa z9s>`6Ujpv|6W~oid<4&ekAnNbX>bZ01>eOF@C9%$I04=Up2r9958xAE6Wk5z;05gc zKL8H`AKU<>4gW6fD6f(OB?n3llpH8IP;#K;!0sGKE!k{wT*qpw___B)5-c?Qnpzjf zj4|9Qq^YUOY`f80!UMA{AF{^6NzKFUDc5+-`~-xhC#RYa6R}s4W6aGGn9VR;60^lKMn05s~ZD zqo>2rR?d&iP?Rn zA?MzF*hPTYw$RzcD}-QRtcjT8K`?Aiod?CvwZ5sc_OV`5e}~(}MbX~D z9E`o*iui=_b>I^m^l|s|3+nwY4p>7f8{7qgKiD2eK`M-useS2~aYzAPuH0dH3ZJbs zRy(bX#g2u!Enb7vidMOSt~rF2r<<#XNuj3iU0H{7)}6TG3DvCGroe^E^A~1r2r3= ztasA8Vn4g|IUnLf%3V}5n7eR~e`)JCJg={9;N0O+$;NQCK-qc^Jfl`rZxAO+Mz3zm z;VoRR#$hOKcugLgpK^i__lI^aVlHdHmuXXKADeMz9rj{dTM09DLdZk}7pzF9j;C~R zj+B~<0}`BFnKaI@^T|#e%6LX~x*@xp;&eTYYimY(XPbI_H&H)Lk?zXE%&pJB?n3llpH8I zP;#K;K*@oU10@H3s5tNv*VbwB^pll5Hn#k|1;0|SrdWj&(!(CB-yXQ####A!QXZTw z1Z$?b)6a>mH6Q238b+1uW3G5?J7&9n*uOi&E{il<`q`N{l0^xKJ)`Zn^Zo@avjIL0 z%q8)Lu+4nFccG>xoHSqY^WqMRvE+Crdb*xsmZ!VWxot00bN88!`z(6IPD)S{%*ZoH z;8`O(LI%N6mHK61$;15szrb)E(an@t$(8xhEY3itS{LlLV7)ORuy_+ zkGjit5iQZ7sQTzh7xt^RCeD7Fq7Vt*}|2ryaUg(=$u;$ldh11@EE}G1JTrzPZXm7NTWhT`JWy zlq=M}g=%gw&I!Bf@J?d7Dh1Bg6wzl{ZKXAm)D)8FpcU5V_H_{|S!LQOr zpH61{!DkZ>dXd^YxiJF?v8abai35CZs?Kn=wxuy%7hf*;Fww|~v1ajh= z-ADAS_VZO6d>1ao?c6Ucb;>G1=x5^hyE}D)J-nzQ&uS_l`l71sf6R2}A&E7|{y$?C z?l-XY9|jMC_k#s63l4!>z}K<&e;s@Zd>nMao!|}N?;!ZM;0xe>a344aPJ>&(f8qo9 z6L2568(a%MkNy8i@L>>uw}9)wllUf{0QZ3bNI(qaY{0YZ`F{+26ub{y55C0y|IdR1 z;5)?ezW}}gz6zcMzX3iDn&5WuR`6=@U1I#71AhfR10Dj4;4bi5@EULpc$)bC-vyiC zcJOBK*TndL5j+Zh2MmCm0k{D?b0uvB9|1iu0iGwu|M$SJfMxJT@C^LdeIWcV3Rm&_ z@C1U!uoDPioz(>l4-V1teozRQ zma1pVj8V_0JlvziJvxGYlUXc#Oke9Ym|kgU?wF32F!VlKTE^FY$@xp_tl34q@?i8K zX-vkc#o-7YA}-gC-Xpy=Z2KK!ldP23=6T3Gl8o6WDiLT_dJ;x4kyK4Px46;|VjVPlpxT&X*DGt5usL!fJRXCnn66 zj0}jv2xWy$3@FTouGt358PAxN$X`unydFemO1~F(tnx&5@5)3Qn;>0H#!AUhU@*(6 zLWQ)FvvJ5e?pP{pn4u}mr3ZXcQW80x@RDO%>Osod5g8d9y)0BxeP=YCj`4g6`%uWA zJ)x2dANo1g4K*nb_bL-ILO7xvc%D;%FH?i)Pn1_C!n`n2)Jq_xn+uEK$XD zV)d?P8$K%w!$;giz_8!+vR@x1AWe06c92GoZKPS#3bj{gy55P))v{!i*o|Dz%N@Cz zMIL0Nip?$P)k_z^oWhw5b&$voeW+)=uw7?2vWeNR&CKB}#_~L<&igYu>|%I^V676T zksLCg$=U_}VZI(x`z|=Tb@op_fto$_imz?-+*-OgEn2g0n2&on5J1#H7Szo-BpqB{ zJU7LRH#*}M>!TZDRzR}T-t@8BVck;02=vl$?WJ-|d%}f$m%T0-Jn{6Yi|Ikv#+}-)dw) zUCMFU9?HuMrdX_Uh53oxvU)W&;Cq(zQpa5lwb80x>Zt3`x@7c#01Ks5U&;i`3TY%o zt_5Wp$oym`!oi-kNe?M#1uhPQU1tl;XwDYZbCsoUUGhxW%Nht{qd^dw!kO8u`8Kpc z0%OeSvg{PEJz+N9^DJZ}{{IT>+N-gF#r{7a_9xfBVB&7=JH=t_Is26=JYK{!N3M@d~|(XQWHL-Md2uI^YnXN|n$ zf>oDu)6JMy5ZPn|`lojo06VyPkp7WqZ45Bi>^<7BXxYQo{ygIs9WodGJ66oX4Y@{@ zg&CPegRkHxyi_3f0a()=^a5iSv;S${bb=Ta;@B@U&YSE?Ae`V}^ga6}3f z!A_&~CFa}6oA$zG!QSZDfR_M4iX+oH)LbIJsj8-eho$hMjkoL9`^CmW!;3O_5%p`mZ}}@yE*{b` z@qT@>$VjI?InVo+c>O@PaQH-So9#EAw7RnWvxU1zjx24lP05=vx(5M2O{(I%y|(-` zbVD2_TR!0pTkB}f3V-FRQTz;R^THJ^WzrdL1by94%7f_SZu-O8s@-48lUf44WGh#uA+Arq1^^u9D17Gqa7+hiH^RH5HE w#uSwtcG4`in;JuB&rCXAj&fK+g_tvW5m37Sh+iwqtX(pB;w;o4OCZeoFD{pd)Bpeg literal 0 HcmV?d00001 diff --git a/finat/functional.py b/finat/functional.py index 46d0b8d0b..2b5400265 100644 --- a/finat/functional.py +++ b/finat/functional.py @@ -55,11 +55,12 @@ class Functional: """ def __init__(self, points: tuple, weights: numpy.ndarray, - order: int = 0, direction=None): + order: int = 0, direction=None, rank: int = 0): self.points = points self.weights = weights self.order = order self.direction = direction + self.rank = rank @classmethod def from_fiat(cls, node: FIATFunctional, tol: float = 1e-12) -> "Functional": @@ -90,14 +91,20 @@ def from_fiat(cls, node: FIATFunctional, tol: float = 1e-12) -> "Functional": if not node.deriv_dict: points = tuple(node.pt_dict) - weights = [] - for pt in points: - wc_list = node.pt_dict[pt] - if len(wc_list) != 1 or wc_list[0][1] != tuple(): - raise NotImplementedError( - f"{type(node).__name__} has vector components.") - weights.append(wc_list[0][0]) - return cls(points, numpy.asarray(weights)) + comps = {comp for pt in points for w, comp in node.pt_dict[pt]} + rank = len(max(comps)) + if rank == 0: + weights = numpy.asarray([w for pt in points + for w, comp in node.pt_dict[pt]]) + return cls(points, weights) + # value weight profile: one row of component weights per point + sd = node.ref_el.get_spatial_dimension() + weights = numpy.zeros((len(points), sd**rank)) + shape = (sd,) * rank + for q, pt in enumerate(points): + for w, comp in node.pt_dict[pt]: + weights[q, numpy.ravel_multi_index(comp, shape)] += w + return cls(points, weights, rank=rank) sd = node.ref_el.get_spatial_dimension() order = node.max_deriv_order @@ -195,6 +202,10 @@ def evaluate(self, fiat_element: FiniteElement) -> numpy.ndarray: """ sd = fiat_element.get_reference_element().get_spatial_dimension() tab = fiat_element.tabulate(self.order, self.points) + if self.rank > 0: + T = tab[(0,) * sd] + T = T.reshape(T.shape[0], -1, len(self.points)) + return numpy.einsum("jcq,qc->j", T, self.weights) if self.order == 0: return tab[(0,) * sd] @ self.weights alphas = multiindices(sd, self.order) diff --git a/finat/johnson_mercier.py b/finat/johnson_mercier.py index 7b6d485cf..5f34f03c2 100644 --- a/finat/johnson_mercier.py +++ b/finat/johnson_mercier.py @@ -1,29 +1,22 @@ import FIAT from gem import ListTensor -from finat.aw import _facet_transform from finat.citations import cite from finat.fiat_elements import FiatElement -from finat.physically_mapped import identity, PhysicallyMappedElement +from finat.physically_mapped import PhysicalGeometry, PhysicallyMappedElement +from finat.zany import zany_basis_transformation class JohnsonMercier(PhysicallyMappedElement, FiatElement): # symmetric matrix valued + """The Johnson-Mercier element. + + The basis transformation is derived automatically from the FIAT + dual basis by :func:`finat.zany.zany_basis_transformation`. + """ def __init__(self, cell, degree=1, variant=None, quad_scheme=None): cite("Gopalakrishnan2024") - self._indices = slice(None, None) - super().__init__(FIAT.JohnsonMercier(cell, degree, variant=variant, quad_scheme=quad_scheme)) - - def basis_transformation(self, coordinate_mapping): - numbf = self._element.space_dimension() - ndof = self.space_dimension() - - V = identity(numbf, ndof) - Vsub = _facet_transform(self.cell, 1, coordinate_mapping) - Vsub = Vsub[:, self._indices] - m, n = Vsub.shape - V[:m, :n] = Vsub + super().__init__(FIAT.JohnsonMercier(cell, degree, variant=variant, + quad_scheme=quad_scheme)) - # Note: that the edge DOFs are scaled by edge lengths in FIAT implies - # that they are already have the necessary rescaling to improve - # conditioning. - return ListTensor(V.T) + def basis_transformation(self, coordinate_mapping: PhysicalGeometry) -> ListTensor: + return zany_basis_transformation(self._element, coordinate_mapping) diff --git a/finat/mtw.py b/finat/mtw.py index 3e79c5d06..f17611b8d 100644 --- a/finat/mtw.py +++ b/finat/mtw.py @@ -1,14 +1,18 @@ import FIAT -from math import comb from gem import ListTensor from finat.citations import cite from finat.fiat_elements import FiatElement -from finat.physically_mapped import identity, PhysicallyMappedElement -from finat.piola_mapped import normal_tangential_transform +from finat.physically_mapped import PhysicalGeometry, PhysicallyMappedElement +from finat.zany import zany_basis_transformation class MardalTaiWinther(PhysicallyMappedElement, FiatElement): + """The Mardal-Tai-Winther element. + + The basis transformation is derived automatically from the FIAT + dual basis by :func:`finat.zany.zany_basis_transformation`. + """ def __init__(self, cell, order=1): if cell.get_spatial_dimension() == 2: cite("Mardal2002") @@ -16,27 +20,5 @@ def __init__(self, cell, order=1): cite("Xie2008") super().__init__(FIAT.MardalTaiWinther(cell, order=order)) - def basis_transformation(self, coordinate_mapping): - sd = self.cell.get_spatial_dimension() - bary, = self.cell.make_points(sd, 0, sd+1) - J = coordinate_mapping.jacobian_at(bary) - detJ = coordinate_mapping.detJ_at(bary) - - V = identity(self.space_dimension()) - q = self._element.order - dimP1 = comb(1+sd-1, 1) - dimPq = comb(q+sd-1, q) - - entity_dofs = self.entity_dofs() - for f in sorted(entity_dofs[sd-1]): - Bnt, Btt = normal_tangential_transform(self.cell, J, detJ, f) - ndofs = entity_dofs[sd-1][f][:dimPq] - tdofs = entity_dofs[sd-1][f][dimPq:] - V[tdofs, tdofs] = Btt - if sd == 2: - V[tdofs, ndofs[0]] = Bnt - else: - V[tdofs[:-1], ndofs[0]] = Bnt - V[tdofs[-1], ndofs[1:dimP1]] = Bnt - - return ListTensor(V.T) + def basis_transformation(self, coordinate_mapping: PhysicalGeometry) -> ListTensor: + return zany_basis_transformation(self._element, coordinate_mapping) diff --git a/finat/zany.py b/finat/zany.py index 0a30d04e2..66195d6d1 100644 --- a/finat/zany.py +++ b/finat/zany.py @@ -228,12 +228,34 @@ def zany_basis_transformation(fiat_element: FiniteElement, nodes = fiat_element.dual_basis() V = identity(fiat_element.space_dimension()) + mappings = set(fiat_element.mapping()) + piola = mappings in ({"contravariant piola"}, + {"double contravariant piola"}) + if not piola and mappings != {"affine"}: + raise NotImplementedError(f"Cannot transform mappings {mappings}.") + processed = set() entity_ids = fiat_element.entity_dofs() for dim in sorted(entity_ids): for entity in sorted(entity_ids[dim]): ells = {i: Functional.from_fiat(nodes[i]) for i in entity_ids[dim][entity]} + if piola: + # Interior moments are Piola invariant by construction + processed.update(i for i, ell in ells.items() + if ell.order == 0 and dim == sd) + group = {i: ell for i, ell in ells.items() + if i not in processed} + if not group: + continue + if dim == sd - 1 and all(ell.rank > 0 and ell.order == 0 + for ell in group.values()): + _piola_facet_rows(V, group, fiat_element, entity, J, + processed, tol) + else: + raise NotImplementedError( + "Cannot yet Piola-transform this node group.") + continue # Value functionals are push-forward invariant processed.update(i for i, ell in ells.items() if ell.order == 0) group = {i: ell for i, ell in ells.items() if ell.order > 0} @@ -358,3 +380,128 @@ def _point_jet_rows(V: numpy.ndarray, group: dict, J: Node, nz = numpy.flatnonzero(abs(x) > tol) V[i, j] = reduce(add, (Jd[m] * x[m] for m in nz)) if len(nz) else Zero() processed.add(i) + + +def _piola_facet_rows(V: numpy.ndarray, group: dict, + fiat_element: FiniteElement, entity: int, J: Node, + processed: set, tol: float) -> None: + r"""Assemble the rows of V for facet moments of Piola-mapped values. + + This mirrors :func:`_facet_rows` with the roles of the normal and + tangential directions exchanged: under the contravariant Piola map + the scaled facet normal is the image of the reference one under the + cofactor matrix :math:`K = \operatorname{adj}(J)^T`, so pure normal + moments are invariant, while the scaled tangents map by :math:`J`. + Each node carries a per-point profile of frame coordinates, shared + by its physical counterpart; the pulled-back reference node mixes + the coordinates through the frame expansion of :math:`K\hat{t}_k`, + the tangential profiles are matched within the group, and the + residual normal profile is eliminated numerically through already + assembled rows of V. + + Parameters + ---------- + V : + Object array being assembled. + group : + Mapping from node index to symbolic Functional for the value + moments on this facet. + fiat_element : + The FIAT element. + entity : + The facet number. + J : + GEM expression for the cell Jacobian. + processed : + Indices of the already assembled rows; updated in place. + tol : + Tolerance for detecting zeros in the numeric coefficients. + """ + ref_el = fiat_element.get_reference_element() + sd = ref_el.get_spatial_dimension() + that = ref_el.compute_tangents(sd - 1, entity) + nhat = ref_el.compute_scaled_normal(entity) + Ghat = numpy.column_stack([nhat, *that]) + Ghatinv = numpy.linalg.inv(Ghat) + + Jnp = numpy.array([[J[i, k] for k in range(sd)] for i in range(sd)], + dtype=object) + K = adjugate(Jnp).T + + # Reference frame coordinate profiles, shared with the physical nodes + coords = {} + for i, ell in group.items(): + C = ell.weights.reshape(-1, *(sd,) * ell.rank) + for _ in range(ell.rank): + C = numpy.tensordot(C, Ghatinv, axes=(1, 1)) + coords[i] = C.reshape(len(ell.points), -1) + # Pure normal moments are Piola invariant + if numpy.allclose(coords[i][:, 1:], 0, atol=tol): + processed.add(i) + + group = {i: ell for i, ell in group.items() if i not in processed} + if not group: + return + rank, = {ell.rank for ell in group.values()} + points, = {ell.points for ell in group.values()} + + # Frame coordinates of the mapped frame image of the reference frame: + # the normal is invariant and the pulled tangents are expanded by a + # symbolic solve in the mapped frame [K nhat | J that_k] + A = numpy.column_stack([K @ nhat, *(Jnp @ t for t in that)]) + adjA = adjugate(A) + detA = determinant(A) + Y = numpy.full((sd, sd), Zero(), dtype=object) + Y[0, 0] = Literal(1.0) + for k, t in enumerate(that): + Y[:, k + 1] = (adjA @ (K @ t)) / detA + + # Physical tangential components are built on the reciprocal basis + # (cross products of the frame), so they carry the in-plane + # contravariant transformation S = adj(G Ghat^{-1})^T of the change + # of tangent Gram matrices. Absorb S^{-1} into the coordinate + # mixing so that the physical profiles keep the reference + # coordinates; in 2D the tangent plane is one-dimensional and S = 1. + G = numpy.array([[Jnp @ t1 @ (Jnp @ t2) for t2 in that] for t1 in that]) + Ghat_t = that @ that.T + Sinv = (numpy.linalg.inv(Ghat_t) @ G) * \ + (numpy.linalg.det(Ghat_t) / determinant(G)) + Y[1:, :] = Sinv @ Y[1:, :] + + # Numeric matching of the tangential coordinate profiles in the group + B = numpy.array([coords[j][:, 1:].ravel() for j in group]) + Bpinv = numpy.linalg.pinv(B) + Bpinv[abs(Bpinv) < tol] = 0 + + # Numeric elimination of the normal profile: one pure normal moment + # per quadrature point, evaluated on the nodal basis + ndir = numpy.ones(()) + for _ in range(rank): + ndir = numpy.multiply.outer(ndir, nhat) + T = fiat_element.tabulate(0, points)[(0,) * sd] + L = numpy.einsum("jcq,c->jq", T.reshape(T.shape[0], -1, len(points)), + ndir.ravel()) + L[abs(L) < tol] = 0 + + for i, ell in group.items(): + # Pull back the coordinate profile, contracting each slot with Y + P = coords[i].reshape(-1, *(sd,) * rank) + for _ in range(rank): + P = numpy.tensordot(P, Y, axes=(1, 1)) + P = P.reshape(len(points), -1) + + row = numpy.full(V.shape[1], Zero(), dtype=object) + c = Bpinv.T @ P[:, 1:].ravel() + for cj, j in zip(c, group): + row[j] = cj + # Residual normal profile after removing the group contribution + residual = P[:, 0] - c @ numpy.array([coords[j][:, 0] for j in group]) + for q in range(len(points)): + for m in numpy.flatnonzero(L[:, q]): + if m not in processed: + raise NotImplementedError( + f"Completion of node {i} couples to node {m}, " + "which has not been transformed yet.") + row = row + V[m, :] * (residual[q] * L[m, q]) + V[i, :] = row + processed.add(i) diff --git a/test/finat/test_zany_automation.py b/test/finat/test_zany_automation.py index ee7816dc5..40fa52d19 100644 --- a/test/finat/test_zany_automation.py +++ b/test/finat/test_zany_automation.py @@ -54,8 +54,8 @@ def test_conditioning_scaling(ref_to_phys, scaled_ref_to_phys, element, dimensio def test_unsupported_nodes(ref_to_phys): - """Vector-valued nodes are not handled yet.""" + """Covariant elements are not handled yet.""" mapping = ref_to_phys[2] - element = FIAT.RaviartThomas(mapping.ref_cell, 1) + element = FIAT.Nedelec(mapping.ref_cell, 1) with pytest.raises(NotImplementedError): zany_basis_transformation(element, mapping) From 5c35006a612c5ae92be32b5c904978fef8320d51 Mon Sep 17 00:00:00 2001 From: Pablo Brubeck Date: Mon, 13 Jul 2026 15:49:19 +0100 Subject: [PATCH 09/34] Automate Guzman-Neilan first kind transformation Vertex and edge point values of contravariant Piola elements are value point-groups whose components pull back through the cofactor matrix K = adj(J)^T, mirroring the Cartesian point-jet case. The trailing tangential facet constraints of the extended element are dropped via ndof, reusing PiolaBubbleElement's reduced entity_dofs bookkeeping. The hand-derived vertex-facet coupling correction of PiolaBubbleElement emerges automatically from the Vandermonde residual elimination. The automatic matrices match the hand-coded ones to machine precision for orders 0-2 in 2D and 3D. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 16 ++++++++--- finat/guzman_neilan.py | 15 ++++++++++- finat/zany.py | 61 +++++++++++++++++++++++++++++++++++++++--- 3 files changed, 84 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 593e59e0b..d6b562b44 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -276,9 +276,19 @@ value slot. MTW (2D/3D) and Johnson-Mercier (2D/3D) are reimplemented this way, the deleted hand code to machine precision; RaviartThomas-type elements come out as pure identity (Piola-equivalent) automatically. -Next steps: Guzman-Neilan (PiolaBubbleElement pattern: vertex point values map by $K$ -like a value point-group, facet bubbles + constraint columns); the extended-element path -for reduced HCT (macro polynomial spaces); covariant elements. +GuzmanNeilanFirstKindH1 (orders 0-2, 2D/3D) is also automatic: vertex/edge point +values are value point-groups mapping by $K$ (`_piola_point_rows`, the mirror of +`_point_jet_rows`), and the trailing tangential facet constraints are dropped with +`ndof` (the Bell pattern); `PiolaBubbleElement.__init__` still provides the reduced +`entity_dofs` bookkeeping. Its hand-derived vertex-facet coupling correction ("fix +discrepancy" in `finat/piola_mapped.py`) emerges automatically from the Vandermonde +residual elimination, since the per-point normal moments evaluate against vertex basis +functions of the extended element. + +Next steps: remaining PiolaBubbleElement users (GN second kind, H1div: interior +derivative moments need the divergence detJ rule), ArnoldWinther (vertex tensor values ++ higher facet moments), the extended-element path for reduced HCT (macro polynomial +spaces); covariant elements. The implementation mirrors the theory factor by factor: diff --git a/finat/guzman_neilan.py b/finat/guzman_neilan.py index fc782147e..b7eb4ba7b 100644 --- a/finat/guzman_neilan.py +++ b/finat/guzman_neilan.py @@ -1,15 +1,28 @@ import FIAT +from gem import ListTensor from finat.citations import cite +from finat.physically_mapped import PhysicalGeometry from finat.piola_mapped import PiolaBubbleElement +from finat.zany import zany_basis_transformation class GuzmanNeilanFirstKindH1(PiolaBubbleElement): - """Pk^d enriched with Guzman-Neilan bubbles.""" + """Pk^d enriched with Guzman-Neilan bubbles. + + The basis transformation is derived automatically from the FIAT + dual basis by :func:`finat.zany.zany_basis_transformation`; the + trailing tangential facet constraints of the extended element are + dropped from the physical element. + """ def __init__(self, cell, order=1, quad_scheme=None): cite("GuzmanNeilan2018") super().__init__(FIAT.GuzmanNeilanFirstKindH1(cell, order=order, quad_scheme=quad_scheme)) + def basis_transformation(self, coordinate_mapping: PhysicalGeometry) -> ListTensor: + return zany_basis_transformation(self._element, coordinate_mapping, + ndof=self.space_dimension()) + class GuzmanNeilanSecondKindH1(PiolaBubbleElement): """C0 Pk^d(Alfeld) enriched with Guzman-Neilan bubbles.""" diff --git a/finat/zany.py b/finat/zany.py index 66195d6d1..b0930a299 100644 --- a/finat/zany.py +++ b/finat/zany.py @@ -248,13 +248,15 @@ def zany_basis_transformation(fiat_element: FiniteElement, if i not in processed} if not group: continue - if dim == sd - 1 and all(ell.rank > 0 and ell.order == 0 - for ell in group.values()): + if any(ell.rank == 0 or ell.order > 0 + for ell in group.values()): + raise NotImplementedError( + "Cannot yet Piola-transform this node group.") + if dim == sd - 1: _piola_facet_rows(V, group, fiat_element, entity, J, processed, tol) else: - raise NotImplementedError( - "Cannot yet Piola-transform this node group.") + _piola_point_rows(V, group, J, processed, tol) continue # Value functionals are push-forward invariant processed.update(i for i, ell in ells.items() if ell.order == 0) @@ -505,3 +507,54 @@ def _piola_facet_rows(V: numpy.ndarray, group: dict, row = row + V[m, :] * (residual[q] * L[m, q]) V[i, :] = row processed.add(i) + + +def _piola_point_rows(V: numpy.ndarray, group: dict, J: Node, + processed: set, tol: float) -> None: + r"""Assemble the rows of V for point values of Piola-mapped fields. + + This mirrors :func:`_point_jet_rows`: away from facets, physical + point evaluations keep the reference (Cartesian) components, which + pull back through the cofactor matrix :math:`K = \mathrm{adj}(J)^T` + of the contravariant Piola map, so the group of components at each + point acts as its own completion. + + Parameters + ---------- + V : + Object array being assembled. + group : + Mapping from node index to symbolic Functional for the value + nodes on this entity. + J : + GEM expression for the cell Jacobian. + processed : + Indices of the already assembled rows; updated in place. + tol : + Tolerance for detecting zeros in the numeric coefficients. + """ + sd = J.shape[0] + Jnp = numpy.array([[J[i, k] for k in range(sd)] for i in range(sd)], + dtype=object) + K = adjugate(Jnp).T + + subgroups = {} + for i, ell in group.items(): + if len(ell.points) != 1 or ell.rank != 1: + raise NotImplementedError( + "Only single-point vector evaluations are handled.") + subgroups.setdefault(ell.points, {})[i] = ell + + for sub in subgroups.values(): + directions = numpy.array([ell.weights[0] for ell in sub.values()]) + if directions.shape[0] != directions.shape[1]: + raise NotImplementedError( + "Directions do not span the vector components.") + Dinv = numpy.linalg.inv(directions.T) + for i, ell in sub.items(): + Kd = K @ ell.weights[0] + for col, j in enumerate(sub): + x = Dinv[col] + nz = numpy.flatnonzero(abs(x) > tol) + V[i, j] = reduce(add, (Kd[m] * x[m] for m in nz)) if len(nz) else Zero() + processed.add(i) From 516ae874bc744ec1498d487e9c749cfcb9186dbe Mon Sep 17 00:00:00 2001 From: Pablo Brubeck Date: Mon, 13 Jul 2026 16:13:38 +0100 Subject: [PATCH 10/34] Remove accidentally committed vim swap files Co-Authored-By: Claude Fable 5 --- finat/.functional.py.swp | Bin 28672 -> 0 bytes finat/.zany.py.swp | Bin 28672 -> 0 bytes 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 finat/.functional.py.swp delete mode 100644 finat/.zany.py.swp diff --git a/finat/.functional.py.swp b/finat/.functional.py.swp deleted file mode 100644 index 5aff1b21999ec7ea0fb13a9adacee91a3102d4f3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 28672 zcmeI4TZ|;vS;xy4NDPFab(HWBoa5O9y6v9coiz?gJG1iIyX$zs3tEQVC^M6un(peE znyIepsj8mK+6yl{I2a3ffLJ04A_`G1QhtFer+{B>@yJB6bw6QV=Y^ z?_8>?duG>5041`jr2p1*)w%x9cfNC}|Jk+U7ao2@y|F##aebHPz3Gn@H$QV`=BAmy zd9}Bej@N?T*3eJ$P+ni_%2R(dyXo(?hkm}1wUfQJ-)oPz4g^1z^-@3YZ5*p6d~6V= zK|l4kg4vxQ-Z_?~Js$Zv|HSg|IPT?P68lkmw0|JQ`a#V=&A<&YFwO_FZ@A67=fvD` z34e6vPIdc{AG@Kbb)cGont_^unt_^unt_^unt_^unt>Mw1NrC{?>Vt;?TyTEu`xLkWejMBf{*8tGUxLqrXTit7)8K>P1K?3` z5!?Z80XKu2z%$?Gd5?p)fQ#S)I1T>mHqZM{@I3eecm{kByc3K;2u^`H@cc_X?^nQk z!8^e(f)(&;aP=jg_eF38ya&7;JPvlj>%d>Laq(B+m%&@W?clj@@w_jCXThhy)8H4t zFh3jKMkJfvaqMd;$C>*aQ!OS@2ahWZnlZfn(rnw2!pW zC%|8TKL=NC@>J#84Z`(}JX7;39uG(R?Mp$LWQ|4?#EnrB#(CCksjPq3U8x^$HJcMr zv%bVi`awGl2HhZPuLpTI8~J(YN8Nrn4B`w8Mq@J4QMKbo<3O2w)hJB{VH9*!5(g?x zc2zP^y<|AzXPnzWnHmMD(xsWmUuuyC`8bVLFUo9jTQ1UMwi5Q5*C0vNZcFE@!q`-4 zlv9OXzBI~LRt_dL&6=P2Y3lDcmUfjSHp(R(!=G2pHXJA+Qq9k+*3Y2)QTgLQKKj9RYH@iYJ5x3i%g3WAXy{h7RW55wY2zd}eDs4f+|iVDyor>g zdV`rq^Zk)YrrGXxV}BTQyO-54+zT?>;C|d!IuYz+$F+afo+-1Flu|#;0(CCQA0ARJ zOehcfXVNrD8=kzDOP30xv&B`Cur7bKh$g5~Gcz-j#a-M8RJK1{OQNuc^^DRWBNNJ$ z#A16nug^YwYEe}rM4n6RY#}Qu9?hDo_X%RA=NG-NZRLv$dHmJQGg-z0^OKW z7BP!i=^F#TmnUg>sanz^5#p&ybCPOavmU}$CA_Tzw<5tG@l%>G57J6a&}pUNo{)6C z`t-xWAV{GUHI{Jdr&J>7B|AK%oyJ4#sOLw~zFONaDiy}-!hDgv^gNMorD2eDbemMN zOj3U6Ru_V#rwo(0sro3wbSz3(BqTKSk~quLv3(bla~NkM!e%;U-4k?jWgcboVLuPL z_{uLWr!)mMNRuJ24rRL^4D|c1h|?N)sg^d?jt*U>(NeTvM-8IH&*_tM!R*|LrkXve zX38)#rh)J8MShmiHXBL5EM^u&gSK@caw`bXFG_0WOPVHw7*gkzzE8d?eVzSl@^#@! z4smx%E}dit(xqz0TV)C6&tcdtZQ{l2rm3ay*6oIIn0LDksYA;N8R^jD4cG!yh z+5xu=B@?|q2(gpYnI6TVu^u|}hzjt?XNl_G zos?HJ8}?PQn=|4Oh907u!iFWBPNED~c2%*@((DXXsw0ZX&g#%+Z3%iY%)pmTgHBY04=~I|La1QRhYb!Fn3##0T8_ z^oCt1m)=6ZCf7;@3l7?zwqsS=H^Yji$&Tm{iR*6+!>p0@TZS2?B58ml$%6UCbR3wG zgOWTlj`DCejBy(bH_-2^b;cMRy0{8#MHgo;x>%{-k z7IuL3Lfo!3G#R+9rD}dnvcp71{fySgaLmGn9#K4y{*Vu+oM`@}ZpqPY+eR zO45C)d(m2RKhW&Z>!ere?%=wm^o)4l)X4FR#`aT)aH49p;D|G&` zsfIrDh`{;wgNe|s!x)B>*prEfoqn`5hf=b#oviP+zW4O1YB)DevtwtvjRJ3%p(N1E zIB#hRv)A2T)u|E~T{?@B;Fz&wXX4JS?-J7U+HJ~?m+z?MWjJXJnhcK1%a~|)TM>l6 zBVT*Z(=Gmv{k6z{+fWS}WYpvV#N%2S?oO$W_N1NF(zUFXjfve`OArfjhQQ*)G;TWd zytL}er>uZD2kofs$@>3|tluvKS^u--*4Hzv^=0kKlyIK}9|AuQB;I47 zzG?<)25JUs25JUs25JUs25JUs25JUs25JVb$pBl*tZ(&aKlAa*k477Q`58(7T-sVu zce9hOw-jY-VN1TqvAGxQnf*;2iZ%Dr@fCF!UpRF@R>JJ|vkR27Y_>ZBI*#mmC3-1c z>_zC^LfLc-_KF=ucC7i?)oC|OW^TXQ?kDO!L|tsR-zn9(p-b<-NL8gKI*Y9GWzub% z%7=h*SL1%WJ#N2F=Kk7}j=7@lSNhYy^p?I!7ha||L)o#Cl=TKoQ?LIYW6}RozR}D2 z-|6?CW3B&L@NV!f@Fchl*1>DQ9pDJ~G;9ASz&1D!UI{*j2H@l139tfQ3I2rip8uNq3ka)hw~Ka-8b)^>09KB7|*s}wj9 zfoLMLbBdTYkH!cq11%skOIKTDn{AP-Rx(e-6)AHeTW~2hnlC7E7X3_-tdx2YmB9{T zL$)CJn69e6ZV~ew*?mGA$Lql5n;bn>$PzuUT+&#|_DFL(@`0e6CbVr~Bcun+dY zac~>>66^aVk_fPZJ7LCyhO0sCMN+zb90J^!zPr@%YFPl9a_ zfP26RFawT&tCaKOU>`gJq}=y|*MXOTf2A(J2i^{(UMIm3@FleTzXi6z_k(-Dt>9_& z{67P>!0W-i;3}H^-v&PmR>3T|dMjrRz{kKg7=iBtqTPQAtN|aK1NVW~f-j@n|3~m= z;4|Q(U1chK6_l+9F|xrZyo4(20t&z&MZJw2UCuO& zjSk-^z8O0Z{Aty8=}~d7r$!@GantV8$g`^M)2IQfzR&O@O3y(2up1drja5bsq}f#k zrQWZd@V}Mg54N|NjZ`$I9FMRX^hWX55<9-`hfEM>ACR#c9-FFbI302s+C~l+0y}H<_Ak_I{E# zm{2UlBgAv{9Kyb*(z| z8r_f>llx*4U~YIGri1!X)+`T1nFqwa?ne(-Y&}_AC}y;tdoK5iM<|}VIja$`>XTp9 zC`CAV7!E~IEv`$9re&jkX@!0R7aiWFajnh^Ezgdg$}pW&LRZOCDDoO zWH_zNLABKuw&>jb0t1B2X?dA`TKe@;aayE$LzB|U(-*p>q`LY*g6lBlU`F+(lYgP3 zO1dI;5L~F7;3(^6Kkm_eM0UNyNh3ebqy0{$4{JfT>Dht#JSfZ`N-a`u?Ex6m4)lCn zw8*_ZNl z+G8T~nukhi^F>L@%Dclk^J=`*X({vlnWeGkm@VSqk#_Rx8yDhQ=PlWXkOK|&96@PI zB_Utmc}q?WG_s|+&Wbw8v@ep41&UP}dB-$bi^5W3Gpa}YKkU(ZJKO0uE|yh4s?O?T zjJg9j9>h?KBD)EcGOkU=I-kS2=8$sf?;Q~rm$unWFteko7+^(z{ogGpb>ZFpdIZ|l zx=cv*DdYoZR9zxBa8}g?Wekw?)Sd3YZG}4tBZC!ZclAItbr4y%VsRF^bcGUy%A2Fc z<=|$wHp*}pi#9pzXm@3{SD9>?M2KiOmGh$QvK;37|Jzsx%Q{)s|0nEH^j}!-%O1e* zfcJw-;7#B@@Kx6QSHM#s0Kc@4-jF2s{XG2VZ2}{}J%( zU<(`tGvHa)_&*00z$?J*zyr^*w*M@6H+UC#5?luB;5Fb5a0GmsdOQIVpzCDoQ{QR^ zY6fZsY6fZsY6ku{47iEV^xdrlCO-FEOPqMfO4@S?eiLn27iE&NEwWa5(S84Mq}Myv xFSuhZEA@KIS}$yjBD&tPPNx=st~Z%`y=7f*SzD2RxvKxhZ&`cJO7Umje*@P$xgG!j diff --git a/finat/.zany.py.swp b/finat/.zany.py.swp deleted file mode 100644 index 9403cd2792b5a2eb0b2102b06104eb3b0ed44db3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 28672 zcmeI5e~ctYb;sNI2ZqFegGC6jpuC6h%zAe2_8e^3<1RU$?~YsUj*VF_!Lxg_y)!+# zy*txA*WI%}?tJ2pC{l<}Vk^jh7>tl&2ue(p80CkABP5grg$Ub;gM}6I4^BjWIFSg! z%KcP-BquuUcGu%@71fmm3<2*PN{d+ryZ`>InLiaF~5G> zyC$xfc#b+=U?Y>Xw}8@D1iX|3%o z65iYKBd;B~8{X8W7i{j0qZYT^gg*g)&$+=)y}vU?tGrQipya?y;J_g1Ouge(&aL~W z_erij6E~^X|JdCxfk2tG~$vpD)<^e?0R1JN9|g-v3EkKw8cV_W3>b`MHtjSK0eF+Rwin zd2Vl)A0-D$4wM`yIZ$$-yb*luTE}@1 zoCSTb2Hp+c4qgYY2LJR*$9Wn&06qv>UO2_#g_$;^peimF0zJ}A~S?~x5K>%(B|43g* ze>@_6B9}5(`e}cTeRRS|%_hnZRP1#-L}T>(Ub~sN!KxP|@lrL*e?%Qr-18#0>z^Y@ zD+=Sd(rC;k4Sz`q8DFSGlCu)Gt4mqV-3UXhF{^$?o%9o-SV^jyL|&1s23bqg26c1E zsObe^)N{K~II`v@YOe|gz5Y(UOZ-Q7wJr}U@}N+CNw!-HG_|y}YF=8gl!GerI!&)z zZ-u@7An}@}^a?EUlbyVfAzG9+k}E9*_NXZ}T~lNE+j*u{FKNboH}TzWv+ei1Aojzc zl9y#@lU@Q}sGCQUq%OI5;zeH2^3uCDSKI5TX44P+q}i-URck|eXVfuy<{dSp(%Q|? zt4U4G&ZyI2+pEqP4oyr<&nw%+n(9a4rr-91Rh2*@YppKN5Gk_6lA6jcU4c0_ za(f=K7n^3}RN8Lh8Y*tkRx=WwG_$<8sD*D?xw@1oKWKY>k3T`8T44~=vk5h6g`04` ztvXTIlgjC$w)|u*rE_Fi*SRo`lpaUYruLwfsIa3Gm|>_=fzfpOYdf*uLgt09aZRVo z?%Cns$7xBn#*2$Jb%f!uxOndou&gOrDbDMe6{J>nG~F2HNI&)lU02bi!;FS8n$Acq z)1qe=7a45llMBs_<+@ULcrAAjdrEhrsb{R*$S_9;XN~AcpJ;yiaT2Gz$R8VFF#}&Z zC*^R~Nm6WZfnG8XRSdhNJ9NgBnQ1i(wLGAkGe0CvyH`dx!b+{Kh!r2k!)g6{uPugQ zix^0I=u+2KLF(nxvi=}mn}WStZq%+jt*#r#>X=mWnB-uH>H0)oGKd1J+1%KTB6p{9 zB-a3~up5i+J*eiRfnlfOI#L%M9<(c!sp+bkoKzL9ZPYEw=cj6m)c4SnK~g!~n5n_< zE_!}9vd7$RZ0L@t_cm%k7e(0(qv(YSzl<6V123+$YpR{>^u2>CVc0d~MZF!a$7^oi zYfPJbhqFv+3)6lwS%wr%7$s}rYKV?kNvJ#q+fLe>i3ppnlvHeSk!DsJNpt#ud=HcO zfJ&O@+f&mQ4ydiA>atc+F8ayJ&erk)dPeziMna@v(`zM6Y&H#pHy*Us5Id%l^d96} z&{tdPpw4O-5VUn~={r{O=13mCZYpC$mREZ`t`jtwB{y9kQKo+n!3X zwDOWyK_F|&7nQ2#RINjGJZC-C0NtIYRXKjIk1VLloYjfPy%234?bMXpULUNwiC1$9 z1UiGDl`u6SH#X<7?2bvd>h;n`c1coWZ4xK*%)4P!({o=<&4pVYKko6O&}0*JP^Xei zo9Semk{OyzSp%CIYw>VQ=CIGwN94;sbK7lOX{j88CQhF)rwm%2C$nH*iLSe9c zH;RoZ-_@%UWb9fwC4d!Q}Pv181a#Xe5@iGVP!MBYzWaY*H_`8?ua;G*UHL zSWBdG?br!96~T1HGBgP(E0uZe((Gs*Rskz+YeN*4a3>?AS4VcbZLPVobRKj)J$31Z zw3)cLnX%hx`?4Htt;xbi)){6(&H122*~WC>Q5PkZ*F}0jI-o!3G8sBEdb*ieHbX1V zUeAYw5h0c&Emm|?L-&*{J-YP2D5Y?aICpxhkp~N5P=_yVRy@Mp$dh#-%qCyF(_0C< zCL=2gCzaDv>e!jdik^QL7w2LhbzEE|onlH@I5Ur2XUx)4R8z6=5am0>$Q3uHb!kIP zABQUm9Hk0woEEyUOx?Ql``EhCh-lhBg{e7Rf;iH!041-R7HvXzAo4mikE6TnS}(q_kbI~>%rsL{=W!LfLDO$u>Zdfo&tXa z9s>`6Ujpv|6W~oid<4&ekAnNbX>bZ01>eOF@C9%$I04=Up2r9958xAE6Wk5z;05gc zKL8H`AKU<>4gW6fD6f(OB?n3llpH8IP;#K;!0sGKE!k{wT*qpw___B)5-c?Qnpzjf zj4|9Qq^YUOY`f80!UMA{AF{^6NzKFUDc5+-`~-xhC#RYa6R}s4W6aGGn9VR;60^lKMn05s~ZD zqo>2rR?d&iP?Rn zA?MzF*hPTYw$RzcD}-QRtcjT8K`?Aiod?CvwZ5sc_OV`5e}~(}MbX~D z9E`o*iui=_b>I^m^l|s|3+nwY4p>7f8{7qgKiD2eK`M-useS2~aYzAPuH0dH3ZJbs zRy(bX#g2u!Enb7vidMOSt~rF2r<<#XNuj3iU0H{7)}6TG3DvCGroe^E^A~1r2r3= ztasA8Vn4g|IUnLf%3V}5n7eR~e`)JCJg={9;N0O+$;NQCK-qc^Jfl`rZxAO+Mz3zm z;VoRR#$hOKcugLgpK^i__lI^aVlHdHmuXXKADeMz9rj{dTM09DLdZk}7pzF9j;C~R zj+B~<0}`BFnKaI@^T|#e%6LX~x*@xp;&eTYYimY(XPbI_H&H)Lk?zXE%&pJB?n3llpH8I zP;#K;K*@oU10@H3s5tNv*VbwB^pll5Hn#k|1;0|SrdWj&(!(CB-yXQ####A!QXZTw z1Z$?b)6a>mH6Q238b+1uW3G5?J7&9n*uOi&E{il<`q`N{l0^xKJ)`Zn^Zo@avjIL0 z%q8)Lu+4nFccG>xoHSqY^WqMRvE+Crdb*xsmZ!VWxot00bN88!`z(6IPD)S{%*ZoH z;8`O(LI%N6mHK61$;15szrb)E(an@t$(8xhEY3itS{LlLV7)ORuy_+ zkGjit5iQZ7sQTzh7xt^RCeD7Fq7Vt*}|2ryaUg(=$u;$ldh11@EE}G1JTrzPZXm7NTWhT`JWy zlq=M}g=%gw&I!Bf@J?d7Dh1Bg6wzl{ZKXAm)D)8FpcU5V_H_{|S!LQOr zpH61{!DkZ>dXd^YxiJF?v8abai35CZs?Kn=wxuy%7hf*;Fww|~v1ajh= z-ADAS_VZO6d>1ao?c6Ucb;>G1=x5^hyE}D)J-nzQ&uS_l`l71sf6R2}A&E7|{y$?C z?l-XY9|jMC_k#s63l4!>z}K<&e;s@Zd>nMao!|}N?;!ZM;0xe>a344aPJ>&(f8qo9 z6L2568(a%MkNy8i@L>>uw}9)wllUf{0QZ3bNI(qaY{0YZ`F{+26ub{y55C0y|IdR1 z;5)?ezW}}gz6zcMzX3iDn&5WuR`6=@U1I#71AhfR10Dj4;4bi5@EULpc$)bC-vyiC zcJOBK*TndL5j+Zh2MmCm0k{D?b0uvB9|1iu0iGwu|M$SJfMxJT@C^LdeIWcV3Rm&_ z@C1U!uoDPioz(>l4-V1teozRQ zma1pVj8V_0JlvziJvxGYlUXc#Oke9Ym|kgU?wF32F!VlKTE^FY$@xp_tl34q@?i8K zX-vkc#o-7YA}-gC-Xpy=Z2KK!ldP23=6T3Gl8o6WDiLT_dJ;x4kyK4Px46;|VjVPlpxT&X*DGt5usL!fJRXCnn66 zj0}jv2xWy$3@FTouGt358PAxN$X`unydFemO1~F(tnx&5@5)3Qn;>0H#!AUhU@*(6 zLWQ)FvvJ5e?pP{pn4u}mr3ZXcQW80x@RDO%>Osod5g8d9y)0BxeP=YCj`4g6`%uWA zJ)x2dANo1g4K*nb_bL-ILO7xvc%D;%FH?i)Pn1_C!n`n2)Jq_xn+uEK$XD zV)d?P8$K%w!$;giz_8!+vR@x1AWe06c92GoZKPS#3bj{gy55P))v{!i*o|Dz%N@Cz zMIL0Nip?$P)k_z^oWhw5b&$voeW+)=uw7?2vWeNR&CKB}#_~L<&igYu>|%I^V676T zksLCg$=U_}VZI(x`z|=Tb@op_fto$_imz?-+*-OgEn2g0n2&on5J1#H7Szo-BpqB{ zJU7LRH#*}M>!TZDRzR}T-t@8BVck;02=vl$?WJ-|d%}f$m%T0-Jn{6Yi|Ikv#+}-)dw) zUCMFU9?HuMrdX_Uh53oxvU)W&;Cq(zQpa5lwb80x>Zt3`x@7c#01Ks5U&;i`3TY%o zt_5Wp$oym`!oi-kNe?M#1uhPQU1tl;XwDYZbCsoUUGhxW%Nht{qd^dw!kO8u`8Kpc z0%OeSvg{PEJz+N9^DJZ}{{IT>+N-gF#r{7a_9xfBVB&7=JH=t_Is26=JYK{!N3M@d~|(XQWHL-Md2uI^YnXN|n$ zf>oDu)6JMy5ZPn|`lojo06VyPkp7WqZ45Bi>^<7BXxYQo{ygIs9WodGJ66oX4Y@{@ zg&CPegRkHxyi_3f0a()=^a5iSv;S${bb=Ta;@B@U&YSE?Ae`V}^ga6}3f z!A_&~CFa}6oA$zG!QSZDfR_M4iX+oH)LbIJsj8-eho$hMjkoL9`^CmW!;3O_5%p`mZ}}@yE*{b` z@qT@>$VjI?InVo+c>O@PaQH-So9#EAw7RnWvxU1zjx24lP05=vx(5M2O{(I%y|(-` zbVD2_TR!0pTkB}f3V-FRQTz;R^THJ^WzrdL1by94%7f_SZu-O8s@-48lUf44WGh#uA+Arq1^^u9D17Gqa7+hiH^RH5HE w#uSwtcG4`in;JuB&rCXAj&fK+g_tvW5m37Sh+iwqtX(pB;w;o4OCZeoFD{pd)Bpeg From 296fb681ea752bdca6cdc577c9f06831c73b9ebb Mon Sep 17 00:00:00 2001 From: Pablo Brubeck Date: Mon, 13 Jul 2026 16:26:33 +0100 Subject: [PATCH 11/34] Enforce pydocstyle; document it in the PR expectations Co-Authored-By: Claude Fable 5 --- AGENTS.md | 7 +++++++ finat/zany.py | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index d6b562b44..5b0186c4b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,13 @@ When assisting with contributions to FIAT and the Firedrake project, AI agents a * Any generated code must be executed locally to verify that it functions correctly. * AI tools must not be used to resolve issues that are labeled as 'good first issue'. +### PR expectations + +* CI (`.github/workflows/test.yml`) checks `flake8` and `pydocstyle .` (both + configured in `setup.cfg`); run them locally and fix all findings before pushing. +* Watch for `pydocstyle` D413 (blank line after the last numpydoc section) and D417 + (every argument, including keyword arguments, needs a description) in new docstrings. + --- ## FIAT's Role in the Architecture diff --git a/finat/zany.py b/finat/zany.py index b0930a299..ff166beca 100644 --- a/finat/zany.py +++ b/finat/zany.py @@ -302,6 +302,10 @@ def _facet_rows(V: numpy.ndarray, group: dict, fiat_element: FiniteElement, Indices of the already assembled rows; updated in place. tol : Tolerance for detecting zeros in the numeric coefficients. + avg : + If False, physical facet moments are plain integrals rather than + integral averages, and their columns are rescaled by the + physical facet measure. """ frame = FacetFrame(fiat_element, entity, J) @@ -418,6 +422,7 @@ def _piola_facet_rows(V: numpy.ndarray, group: dict, Indices of the already assembled rows; updated in place. tol : Tolerance for detecting zeros in the numeric coefficients. + """ ref_el = fiat_element.get_reference_element() sd = ref_el.get_spatial_dimension() @@ -532,6 +537,7 @@ def _piola_point_rows(V: numpy.ndarray, group: dict, J: Node, Indices of the already assembled rows; updated in place. tol : Tolerance for detecting zeros in the numeric coefficients. + """ sd = J.shape[0] Jnp = numpy.array([[J[i, k] for k in range(sd)] for i in range(sd)], From 12720b829bc2580e157f19715d8f9e3c0bdf465b Mon Sep 17 00:00:00 2001 From: Pablo Brubeck Date: Mon, 13 Jul 2026 16:54:25 +0100 Subject: [PATCH 12/34] Refactor automatic transformation into two PhysicallyMappedElement mixins Replace the free function zany_basis_transformation and its internal "if piola" branch with a template-method design: - PhysicallyMappedElement.basis_transformation (physically_mapped.py) now implements the entity-by-entity assembly loop directly, calling four hooks -- _check_mapping, _invariant_dofs, _facet_dof_rows, _point_dof_rows -- that carry all mapping-specific knowledge. The loop itself has no branch on the kind of pullback. - finat/zany.py supplies the two mixins implementing those hooks: ScalarPhysicallyMappedElement (affine pullback) and PiolaPhysicallyMappedElement ((double) contravariant Piola), plus the pure math functions they call (FacetFrame, _scalar_facet_rows, _scalar_point_rows, _piola_facet_rows, _piola_point_rows), which take plain arrays/GEM expressions with no self, keeping the mathematics readable independent of the class plumbing. - Concrete elements (Morley, Hermite, Argyris, Bell, MardalTaiWinther, JohnsonMercier, GuzmanNeilanFirstKindH1) drop their basis_transformation override entirely: mixing in the right base class is enough. The ndof truncation parameter is gone; the loop always slices by self.space_dimension(), which constrained elements already override. - finat.Functional is renamed to finat.PhysicallyMappedFunctional. Full finat suite, flake8, and pydocstyle all pass unchanged; the automatic matrices are byte-for-byte the same computation as before, just reorganized. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 75 +++-- finat/__init__.py | 2 +- finat/argyris.py | 15 +- finat/bell.py | 17 +- finat/functional.py | 16 +- finat/guzman_neilan.py | 17 +- finat/hermite.py | 11 +- finat/johnson_mercier.py | 11 +- finat/morley.py | 11 +- finat/mtw.py | 11 +- finat/physically_mapped.py | 153 +++++++++- finat/zany.py | 438 ++++++++++++----------------- test/finat/test_zany_automation.py | 48 +++- 13 files changed, 435 insertions(+), 390 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5b0186c4b..b50ccb519 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -165,25 +165,32 @@ Goal: replace the hand-coded `basis_transformation` methods with a helper that d $V = E V^c D$ directly from a FIAT element's dual basis. The implementation must mirror the theory factor by factor, not merely reproduce matrix entries: -**Status (2026-07-13).** The framework lives in `finat/functional.py` (the symbolic -`finat.Functional`) and `finat/zany.py` (`FacetFrame`, `zany_basis_transformation`). -`finat.Morley`, `finat.Hermite`, `finat.Argyris` (both variants, degrees 5-7 tested, -with the `avg` convention flag), and `finat.Bell` are reimplemented on it: `basis_transformation` is -one call to `zany_basis_transformation(self._element, coordinate_mapping)`, working in -2D and 3D through a single dimension-independent code path, verified by -`check_zany_mapping` and matching the previous hand-coded matrices (including the -$h$-scaling) to machine precision before that code was deleted. `morley_transform` -moved verbatim to `finat/walkington.py`, its only remaining user. Tests: -`test/finat/test_zany_automation.py`; `check_zany_mapping` lives in the finat conftest -and is provided to test modules as a pytest fixture (pytest runs with -`--import-mode=importlib`, so test modules cannot import from each other or from -conftest — recover classes from fixture instances via `type(...)` if needed). - -**Framework design.** A dof is a symbolic `finat.Functional`: +**Status (2026-07-13).** The symbolic dof lives in `finat/functional.py` as +`finat.PhysicallyMappedFunctional`. The OOP structure is a template method: the +entity-by-entity assembly loop is implemented once, as the concrete +`PhysicallyMappedElement.basis_transformation` in `finat/physically_mapped.py`, calling +four hooks (`_check_mapping`, `_invariant_dofs`, `_facet_dof_rows`, `_point_dof_rows`) +that carry ALL mapping-specific knowledge — the loop itself contains no `if piola` +anywhere. `finat/zany.py` supplies the two mixins implementing those hooks, +`ScalarPhysicallyMappedElement` (affine pullback: Morley, Hermite, Argyris, Bell) and +`PiolaPhysicallyMappedElement` ((double) contravariant Piola: MTW, Johnson-Mercier, +Guzman-Neilan), plus the pure math functions they call (`FacetFrame`, +`_scalar_facet_rows`, `_scalar_point_rows`, `_piola_facet_rows`, `_piola_point_rows`) — +these take plain arrays/GEM expressions, no `self`, so the mathematics stays readable +independent of the class plumbing. Concrete elements (`finat.Morley`, etc.) are now +just a citation plus a FIAT constructor call: mixing in the right base class is enough, +`basis_transformation` is inherited. `ndof` truncation is no longer a parameter; the +loop always slices by `self.space_dimension()`, which constrained elements (Bell, GN) +already override. Tests: `test/finat/test_zany_automation.py`; `check_zany_mapping` +lives in the finat conftest and is provided to test modules as a pytest fixture (pytest +runs with `--import-mode=importlib`, so test modules cannot import from each other or +from conftest). + +**Framework design.** A dof is a symbolic `finat.PhysicallyMappedFunctional`: $\ell(f) = \sum_q w_q \langle D, \nabla^m f(x_q)\rangle$ with numeric points/weights and a direction tensor $D$ that is numeric on the reference cell and GEM otherwise. -There is *no dispatch over FIAT functional types*: `Functional.from_fiat` reads only -`pt_dict`/`deriv_dict` and recovers the order and common direction numerically +There is *no dispatch over FIAT functional types*: `PhysicallyMappedFunctional.from_fiat` +reads only `pt_dict`/`deriv_dict` and recovers the order and common direction numerically (rank-one SVD of the derivative weights). Operations: covariant `pullback(J)` (contract direction slots with $J$), `with_direction`, and numeric `evaluate` against a nodal basis (the generalized Vandermonde realizing the $D$ factor of $V = E V^c D$). @@ -200,12 +207,12 @@ The row of $V$ for a reference node $\hat\ell$ with direction $\hat d = a\hat n invariant under that sign flip; a sign bug here flips exactly the edges where the SVD chose the opposite orientation). * the remainders multiply completion functionals along *mapped reference tangents*, - which coincide with reference functionals; `Functional.evaluate` gives their numeric + which coincide with reference functionals; `PhysicallyMappedFunctional.evaluate` gives their numeric expansion in the element's own nodes, and the row combination recurses through the already-assembled rows of $V$ (entities processed in increasing dimension), which will later let completions couple to vertex jets (Argyris/HCT) for free. -Derivative nodes *away from facets* (`_point_jet_rows`, covering Hermite vertex +Derivative nodes *away from facets* (`_scalar_point_rows`, covering Hermite vertex gradients) have no geometric frame: FIAT keeps Cartesian directions on the physical cell, so the group of derivative nodes on the entity acts as its own completion — this is precisely affine-interpolation equivalence. The pulled-back direction $J\hat d_i$ @@ -239,7 +246,7 @@ Key facts the framework rests on: Extensions beyond first order and Morley/Hermite: -* `Functional` directions live in derivative multi-index space (`multiindices`, axis +* `PhysicallyMappedFunctional` directions live in derivative multi-index space (`multiindices`, axis order for $m=1$); `pullback` distributes them over a symmetric tensor (dividing by multiplicities), contracts every slot with $J$ (`numpy.tensordot` on object arrays), and collapses back. Point-jet groups are split per order; each order solves in its @@ -248,20 +255,22 @@ Extensions beyond first order and Morley/Hermite: moments; the existing row recursion handles both with no new code (trace moments are order-0 and thus invariant; FIAT builds all these moments with `FacetQuadratureRule(avg=True)`, i.e. measure-intrinsic, as the framework assumes). -* `zany_basis_transformation(avg=False)` reproduces the legacy FInAT convention where - physical facet moments are plain integrals: their columns are divided by the - physical facet measure $\|C\| |\hat e| / \|\hat C\|$ (`FacetFrame.measure`). - Single-point facet dofs (Argyris "point" variant) are unaffected. +* `ScalarPhysicallyMappedElement.avg = False` (an instance attribute Argyris sets from + its constructor kwarg) reproduces the legacy FInAT convention where physical facet + moments are plain integrals: their columns are divided by the physical facet measure + $\|C\| |\hat e| / \|\hat C\|$ (`FacetFrame.measure`). Single-point facet dofs + (Argyris "point" variant) are unaffected. * Bell is the extended-element pattern: FIAT.Bell is the 21-node quintic element with - the constraint functionals as extra edge nodes; `ndof=18` drops the constraint - *columns* of $V$ (their rows still contribute the $D$-matrix entries through the - completion recursion), and the FInAT element overrides `entity_dofs`. + the constraint functionals as extra edge nodes; overriding `space_dimension()` to 18 + drops the constraint *columns* of $V$ at the end of the template method (their rows + still contribute the $D$-matrix entries through the completion recursion), and the + FInAT element overrides `entity_dofs`. * Known convention change: the generic $h^{-m}$ conditioning scaling now also applies to integral-variant Argyris edge moments, which the hand-written code left unscaled (Morley scaled them; the legacy convention was inconsistent). Invisible when `cell_size == 1`; flag in PR review. -**Piola-mapped elements** (Aznaran, Kirby & Farrell 2022). `Functional` carries a +**Piola-mapped elements** (Aznaran, Kirby & Farrell 2022). `PhysicallyMappedFunctional` carries a value rank: component weight profiles (nq x sd^rank) parsed from `pt_dict` component tuples. Under contravariant Piola the roles of the scalar case are mirrored: the *scaled* facet normal is the cofactor image $K\hat n_s$, $K = \mathrm{adj}(J)^T$ @@ -285,9 +294,13 @@ identity (Piola-equivalent) automatically. GuzmanNeilanFirstKindH1 (orders 0-2, 2D/3D) is also automatic: vertex/edge point values are value point-groups mapping by $K$ (`_piola_point_rows`, the mirror of -`_point_jet_rows`), and the trailing tangential facet constraints are dropped with -`ndof` (the Bell pattern); `PiolaBubbleElement.__init__` still provides the reduced -`entity_dofs` bookkeeping. Its hand-derived vertex-facet coupling correction ("fix +`_scalar_point_rows`), and the trailing tangential facet constraints are dropped since +`PiolaBubbleElement.space_dimension()` already returns the reduced count (the Bell +pattern, inherited rather than passed as a parameter); `PiolaBubbleElement.__init__` +still provides the reduced `entity_dofs` bookkeeping. `GuzmanNeilanFirstKindH1` is +`class GuzmanNeilanFirstKindH1(PiolaPhysicallyMappedElement, PiolaBubbleElement)`: MRO +puts the automatic `basis_transformation` first, while `PiolaBubbleElement.__init__` +(reached via `super()`) still sets up `self._element` and the reduced dof bookkeeping. Its hand-derived vertex-facet coupling correction ("fix discrepancy" in `finat/piola_mapped.py`) emerges automatically from the Vandermonde residual elimination, since the per-point normal moments evaluate against vertex basis functions of the extended element. diff --git a/finat/__init__.py b/finat/__init__.py index 11601584e..bcefc5f66 100644 --- a/finat/__init__.py +++ b/finat/__init__.py @@ -43,7 +43,7 @@ from .nodal_enriched import NodalEnrichedElement # noqa: F401 from .quadrature_element import QuadratureElement, make_quadrature_element # noqa: F401 from .restricted import RestrictedElement # noqa: F401 -from .functional import Functional # noqa: F401 +from .functional import PhysicallyMappedFunctional # noqa: F401 from .runtime_tabulated import RuntimeTabulated # noqa: F401 from . import quadrature # noqa: F401 from . import cell_tools # noqa: F401 diff --git a/finat/argyris.py b/finat/argyris.py index 555e68847..1d4d0913e 100644 --- a/finat/argyris.py +++ b/finat/argyris.py @@ -4,13 +4,12 @@ import FIAT -from gem import Literal, ListTensor, Zero +from gem import Literal, Zero from finat.citations import cite from finat.fiat_elements import ScalarFiatElement -from finat.physically_mapped import (identity, PhysicalGeometry, - PhysicallyMappedElement) -from finat.zany import zany_basis_transformation +from finat.physically_mapped import identity +from finat.zany import ScalarPhysicallyMappedElement def _jet_transform(J, order): @@ -129,11 +128,11 @@ def _edge_transform(V, vorder, eorder, fiat_cell, coordinate_mapping, avg=False) V[s, s + eorder] = -Bnt -class Argyris(PhysicallyMappedElement, ScalarFiatElement): +class Argyris(ScalarPhysicallyMappedElement, ScalarFiatElement): """The Argyris element. The basis transformation is derived automatically from the FIAT - dual basis by :func:`finat.zany.zany_basis_transformation`. + dual basis; see :class:`finat.zany.ScalarPhysicallyMappedElement`. """ def __init__(self, cell, degree=5, variant=None, avg=False): cite("Argyris1968") @@ -144,7 +143,3 @@ def __init__(self, cell, degree=5, variant=None, avg=False): self.variant = variant self.avg = avg super().__init__(FIAT.Argyris(cell, degree, variant=variant)) - - def basis_transformation(self, coordinate_mapping: PhysicalGeometry) -> ListTensor: - return zany_basis_transformation(self._element, coordinate_mapping, - avg=self.avg) diff --git a/finat/bell.py b/finat/bell.py index 00d38924b..7cfdc3057 100644 --- a/finat/bell.py +++ b/finat/bell.py @@ -1,23 +1,20 @@ import FIAT from copy import deepcopy -from gem import ListTensor - from finat.citations import cite from finat.fiat_elements import ScalarFiatElement -from finat.physically_mapped import PhysicalGeometry, PhysicallyMappedElement -from finat.zany import zany_basis_transformation +from finat.zany import ScalarPhysicallyMappedElement -class Bell(PhysicallyMappedElement, ScalarFiatElement): +class Bell(ScalarPhysicallyMappedElement, ScalarFiatElement): """The Bell element. FIAT provides the extended element on the full quintic space, with the cubic normal derivative constraints appended as extra edge nodes; the transformation of the extended element is derived - automatically by :func:`finat.zany.zany_basis_transformation`, and - the constraint degrees of freedom are dropped from the physical - element. + automatically (see :class:`finat.zany.ScalarPhysicallyMappedElement`), + and the constraint degrees of freedom are dropped from the physical + element by overriding :meth:`space_dimension`. """ def __init__(self, cell, degree=5): cite("Bell1969") @@ -29,10 +26,6 @@ def __init__(self, cell, degree=5): reduced_dofs[sd-1][entity] = [] self._entity_dofs = reduced_dofs - def basis_transformation(self, coordinate_mapping: PhysicalGeometry) -> ListTensor: - return zany_basis_transformation(self._element, coordinate_mapping, - ndof=self.space_dimension()) - # The extended FIAT element has 21 basis functions, but the Bell # element only keeps the 18 vertex degrees of freedom. def entity_dofs(self): diff --git a/finat/functional.py b/finat/functional.py index 2b5400265..6f585819d 100644 --- a/finat/functional.py +++ b/finat/functional.py @@ -1,6 +1,6 @@ r"""Symbolic representation of degrees of freedom. -A :class:`Functional` represents a degree of freedom in the form +A :class:`PhysicallyMappedFunctional` represents a degree of freedom in the form .. math:: \\ell(f) = \\sum_q w_q \\langle D, \\nabla^m f(x_q) \\rangle, @@ -37,7 +37,7 @@ def multiindices(sd: int, order: int) -> list: return sorted(mis(sd, order), reverse=True) -class Functional: +class PhysicallyMappedFunctional: """Symbolic degree of freedom with a single derivative direction. Parameters @@ -63,8 +63,8 @@ def __init__(self, points: tuple, weights: numpy.ndarray, self.rank = rank @classmethod - def from_fiat(cls, node: FIATFunctional, tol: float = 1e-12) -> "Functional": - """Construct a symbolic Functional from a FIAT functional. + def from_fiat(cls, node: FIATFunctional, tol: float = 1e-12) -> "PhysicallyMappedFunctional": + """Construct a symbolic PhysicallyMappedFunctional from a FIAT functional. The construction only inspects the point and derivative dictionaries: the derivative order and the (common) direction of @@ -81,7 +81,7 @@ def from_fiat(cls, node: FIATFunctional, tol: float = 1e-12) -> "Functional": Returns ------- - Functional + PhysicallyMappedFunctional The symbolic representation of the FIAT functional. """ @@ -129,12 +129,12 @@ def from_fiat(cls, node: FIATFunctional, tol: float = 1e-12) -> "Functional": weights = u[:, 0] * s[0] return cls(points, weights, order=order, direction=direction) - def with_direction(self, direction) -> "Functional": + def with_direction(self, direction) -> "PhysicallyMappedFunctional": """Return the same functional with another direction tensor.""" return type(self)(self.points, self.weights, order=self.order, direction=direction) - def pullback(self, J: Node) -> "Functional": + def pullback(self, J: Node) -> "PhysicallyMappedFunctional": r"""View this reference functional as acting on physical functions. By the chain rule, reference derivatives of a pullback are @@ -149,7 +149,7 @@ def pullback(self, J: Node) -> "Functional": Returns ------- - Functional + PhysicallyMappedFunctional The functional with direction :math:`J \\otimes \\dots \\otimes J : D`, acting on physical derivatives at the images of the reference points. diff --git a/finat/guzman_neilan.py b/finat/guzman_neilan.py index b7eb4ba7b..8c01d4ff5 100644 --- a/finat/guzman_neilan.py +++ b/finat/guzman_neilan.py @@ -1,28 +1,23 @@ import FIAT -from gem import ListTensor from finat.citations import cite -from finat.physically_mapped import PhysicalGeometry from finat.piola_mapped import PiolaBubbleElement -from finat.zany import zany_basis_transformation +from finat.zany import PiolaPhysicallyMappedElement -class GuzmanNeilanFirstKindH1(PiolaBubbleElement): +class GuzmanNeilanFirstKindH1(PiolaPhysicallyMappedElement, PiolaBubbleElement): """Pk^d enriched with Guzman-Neilan bubbles. The basis transformation is derived automatically from the FIAT - dual basis by :func:`finat.zany.zany_basis_transformation`; the - trailing tangential facet constraints of the extended element are - dropped from the physical element. + dual basis (see :class:`finat.zany.PiolaPhysicallyMappedElement`); + the trailing tangential facet constraints of the extended element + are dropped from the physical element by + :meth:`~finat.piola_mapped.PiolaBubbleElement.space_dimension`. """ def __init__(self, cell, order=1, quad_scheme=None): cite("GuzmanNeilan2018") super().__init__(FIAT.GuzmanNeilanFirstKindH1(cell, order=order, quad_scheme=quad_scheme)) - def basis_transformation(self, coordinate_mapping: PhysicalGeometry) -> ListTensor: - return zany_basis_transformation(self._element, coordinate_mapping, - ndof=self.space_dimension()) - class GuzmanNeilanSecondKindH1(PiolaBubbleElement): """C0 Pk^d(Alfeld) enriched with Guzman-Neilan bubbles.""" diff --git a/finat/hermite.py b/finat/hermite.py index 153d0e947..a62a7cf4a 100644 --- a/finat/hermite.py +++ b/finat/hermite.py @@ -1,21 +1,16 @@ import FIAT -from gem import ListTensor from finat.citations import cite from finat.fiat_elements import ScalarFiatElement -from finat.physically_mapped import PhysicalGeometry, PhysicallyMappedElement -from finat.zany import zany_basis_transformation +from finat.zany import ScalarPhysicallyMappedElement -class Hermite(PhysicallyMappedElement, ScalarFiatElement): +class Hermite(ScalarPhysicallyMappedElement, ScalarFiatElement): """The cubic Hermite element. The basis transformation is derived automatically from the FIAT - dual basis by :func:`finat.zany.zany_basis_transformation`. + dual basis; see :class:`finat.zany.ScalarPhysicallyMappedElement`. """ def __init__(self, cell, degree=3): cite("Ciarlet1972") super().__init__(FIAT.CubicHermite(cell)) - - def basis_transformation(self, coordinate_mapping: PhysicalGeometry) -> ListTensor: - return zany_basis_transformation(self._element, coordinate_mapping) diff --git a/finat/johnson_mercier.py b/finat/johnson_mercier.py index 5f34f03c2..b1af74c49 100644 --- a/finat/johnson_mercier.py +++ b/finat/johnson_mercier.py @@ -1,22 +1,17 @@ import FIAT -from gem import ListTensor from finat.citations import cite from finat.fiat_elements import FiatElement -from finat.physically_mapped import PhysicalGeometry, PhysicallyMappedElement -from finat.zany import zany_basis_transformation +from finat.zany import PiolaPhysicallyMappedElement -class JohnsonMercier(PhysicallyMappedElement, FiatElement): # symmetric matrix valued +class JohnsonMercier(PiolaPhysicallyMappedElement, FiatElement): # symmetric matrix valued """The Johnson-Mercier element. The basis transformation is derived automatically from the FIAT - dual basis by :func:`finat.zany.zany_basis_transformation`. + dual basis; see :class:`finat.zany.PiolaPhysicallyMappedElement`. """ def __init__(self, cell, degree=1, variant=None, quad_scheme=None): cite("Gopalakrishnan2024") super().__init__(FIAT.JohnsonMercier(cell, degree, variant=variant, quad_scheme=quad_scheme)) - - def basis_transformation(self, coordinate_mapping: PhysicalGeometry) -> ListTensor: - return zany_basis_transformation(self._element, coordinate_mapping) diff --git a/finat/morley.py b/finat/morley.py index c8cd20cf1..c9605841a 100644 --- a/finat/morley.py +++ b/finat/morley.py @@ -1,22 +1,17 @@ import FIAT -from gem import ListTensor from finat.citations import cite from finat.fiat_elements import ScalarFiatElement -from finat.physically_mapped import PhysicalGeometry, PhysicallyMappedElement -from finat.zany import zany_basis_transformation +from finat.zany import ScalarPhysicallyMappedElement -class Morley(PhysicallyMappedElement, ScalarFiatElement): +class Morley(ScalarPhysicallyMappedElement, ScalarFiatElement): """The Morley element on simplices of any dimension. The basis transformation is derived automatically from the FIAT - dual basis by :func:`finat.zany.zany_basis_transformation`. + dual basis; see :class:`finat.zany.ScalarPhysicallyMappedElement`. """ def __init__(self, cell, degree=2): cite("Morley1971") cite("MingXu2006") super().__init__(FIAT.Morley(cell, degree=degree)) - - def basis_transformation(self, coordinate_mapping: PhysicalGeometry) -> ListTensor: - return zany_basis_transformation(self._element, coordinate_mapping) diff --git a/finat/mtw.py b/finat/mtw.py index f17611b8d..aea10ebb5 100644 --- a/finat/mtw.py +++ b/finat/mtw.py @@ -1,17 +1,15 @@ import FIAT -from gem import ListTensor from finat.citations import cite from finat.fiat_elements import FiatElement -from finat.physically_mapped import PhysicalGeometry, PhysicallyMappedElement -from finat.zany import zany_basis_transformation +from finat.zany import PiolaPhysicallyMappedElement -class MardalTaiWinther(PhysicallyMappedElement, FiatElement): +class MardalTaiWinther(PiolaPhysicallyMappedElement, FiatElement): """The Mardal-Tai-Winther element. The basis transformation is derived automatically from the FIAT - dual basis by :func:`finat.zany.zany_basis_transformation`. + dual basis; see :class:`finat.zany.PiolaPhysicallyMappedElement`. """ def __init__(self, cell, order=1): if cell.get_spatial_dimension() == 2: @@ -19,6 +17,3 @@ def __init__(self, cell, order=1): else: cite("Xie2008") super().__init__(FIAT.MardalTaiWinther(cell, order=order)) - - def basis_transformation(self, coordinate_mapping: PhysicalGeometry) -> ListTensor: - return zany_basis_transformation(self._element, coordinate_mapping) diff --git a/finat/physically_mapped.py b/finat/physically_mapped.py index 511d3e146..66e59b9cd 100644 --- a/finat/physically_mapped.py +++ b/finat/physically_mapped.py @@ -1,10 +1,13 @@ from abc import ABCMeta, abstractmethod from collections.abc import Mapping +from functools import reduce +from operator import add import gem import numpy from finat.citations import cite +from finat.functional import PhysicallyMappedFunctional class NeedsCoordinateMappingElement(metaclass=ABCMeta): @@ -65,7 +68,19 @@ def __len__(self): class PhysicallyMappedElement(NeedsCoordinateMappingElement): """A mixin that applies a "physical" transformation to tabulated - basis functions.""" + basis functions. + + Concrete elements either implement :meth:`basis_transformation` + entirely by hand, or derive it automatically by mixing in + :class:`~finat.zany.ScalarPhysicallyMappedElement` or + :class:`~finat.zany.PiolaPhysicallyMappedElement`, which supply the + four hooks below and inherit the entity-by-entity assembly loop + implemented here. + """ + + #: Numerical tolerance used throughout automatic basis transformation + #: to detect vanishing coefficients. + tol = 1e-12 def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -73,12 +88,109 @@ def __init__(self, *args, **kwargs): cite("Kirby2019zany") self.restriction_indices = None - @abstractmethod def basis_transformation(self, coordinate_mapping): - """Transformation matrix for the basis functions. - - :arg coordinate_mapping: Object providing physical geometry.""" - pass + r"""Assemble the basis transformation :math:`M = V^T`. + + Following the factorization :math:`V = E V^c D` of Kirby (2017) + and Brubeck & Kirby (2025), the matrix :math:`V` relating the + reference nodes to the push-forwards of the physical nodes is + assembled one topological entity at a time, in increasing + dimension, so that the completion of a node on an entity can + always be resolved against the already-assembled rows of + lower-dimensional entities. + + On each entity, nodes that are already push-forward invariant + (:meth:`_invariant_dofs`) contribute an identity row for free, + since :math:`V` starts out as the identity. The rest are + assembled by :meth:`_facet_dof_rows` (on a codimension-1 + entity) or :meth:`_point_dof_rows` (elsewhere); these three + hooks, together with :meth:`_check_mapping`, encode the + mapping-specific (affine or Piola) part of the theory, and this + method contains no knowledge of which mapping is in play. + + :arg coordinate_mapping: Object providing physical geometry. + """ + fiat_element = self._element + self._check_mapping(fiat_element) + + ref_el = fiat_element.get_reference_element() + sd = ref_el.get_spatial_dimension() + bary, = ref_el.make_points(sd, 0, sd + 1) + J = coordinate_mapping.jacobian_at(bary) + + nodes = fiat_element.dual_basis() + V = identity(fiat_element.space_dimension()) + + processed = set() + entity_ids = fiat_element.entity_dofs() + for dim in sorted(entity_ids): + for entity in sorted(entity_ids[dim]): + group = {i: PhysicallyMappedFunctional.from_fiat(nodes[i]) + for i in entity_ids[dim][entity]} + invariant = self._invariant_dofs(group, dim, sd) + processed.update(invariant) + group = {i: ell for i, ell in group.items() if i not in invariant} + if not group: + continue + if dim == sd - 1: + self._facet_dof_rows(V, group, fiat_element, entity, J, processed) + else: + self._point_dof_rows(V, group, fiat_element, J, processed) + + _rescale_derivative_dofs(V, fiat_element, coordinate_mapping) + ndof = self.space_dimension() + return gem.ListTensor(V[:, :ndof].T) + + def _check_mapping(self, fiat_element): + """Verify that this class knows how to transform this element's pullback. + + :arg fiat_element: The FIAT element defined on the reference cell. + :raises NotImplementedError: If the pullback is not supported. + """ + raise NotImplementedError( + f"{type(self).__name__} does not implement automatic basis transformation.") + + def _invariant_dofs(self, group, dim, sd): + """Select the nodes of an entity that are already push-forward invariant. + + :arg group: Dict mapping node index to :class:`PhysicallyMappedFunctional` + for the reference nodes associated with one entity. + :arg dim: Topological dimension of the entity. + :arg sd: Spatial dimension of the cell. + :returns: The subset of ``group`` keys whose row of :math:`V` is + the identity row. + """ + raise NotImplementedError( + f"{type(self).__name__} does not implement automatic basis transformation.") + + def _facet_dof_rows(self, V, group, fiat_element, entity, J, processed): + """Assemble the rows of V for the non-invariant nodes on a facet. + + :arg V: Object array being assembled; rows are set in place. + :arg group: Dict mapping node index to :class:`PhysicallyMappedFunctional` + for the non-invariant reference nodes on this facet. + :arg fiat_element: The FIAT element defined on the reference cell. + :arg entity: The facet number. + :arg J: GEM expression for the cell Jacobian. + :arg processed: Indices of the already assembled rows of ``V``; + updated in place. + """ + raise NotImplementedError( + f"{type(self).__name__} does not implement automatic basis transformation.") + + def _point_dof_rows(self, V, group, fiat_element, J, processed): + """Assemble the rows of V for the non-invariant nodes away from a facet. + + :arg V: Object array being assembled; rows are set in place. + :arg group: Dict mapping node index to :class:`PhysicallyMappedFunctional` + for the non-invariant reference nodes on this entity. + :arg fiat_element: The FIAT element defined on the reference cell. + :arg J: GEM expression for the cell Jacobian. + :arg processed: Indices of the already assembled rows of ``V``; + updated in place. + """ + raise NotImplementedError( + f"{type(self).__name__} does not implement automatic basis transformation.") def map_tabulation(self, ref_tabulation, coordinate_mapping): assert coordinate_mapping is not None @@ -206,6 +318,35 @@ def identity(*shape): return V +def _rescale_derivative_dofs(V, fiat_element, coordinate_mapping): + r"""Rescale derivative degrees of freedom by the cell size. + + Each physical node of derivative order :math:`m` is redefined with a + factor :math:`h^{-m}`, where :math:`h` averages the cell size over + the vertices of its entity. This is the FInAT convention keeping + the mass matrix well-conditioned; it is consistent across cells + because the scaling only depends on shared entities. + + :arg V: Object array being assembled; columns are rescaled in place. + :arg fiat_element: The FIAT element defined on the reference cell. + :arg coordinate_mapping: Object providing the physical geometry as + GEM expressions. + """ + # cell_size may be a GEM expression or a numpy array of numbers + h = coordinate_mapping.cell_size() + top = fiat_element.get_reference_element().get_topology() + nodes = fiat_element.dual_basis() + entity_ids = fiat_element.entity_dofs() + for dim in entity_ids: + for entity in entity_ids[dim]: + verts = top[dim][entity] + havg = reduce(add, (h[v] for v in verts)) / len(verts) + for i in entity_ids[dim][entity]: + order = nodes[i].max_deriv_order + if order > 0: + V[:, i] = V[:, i] * havg**(-order) + + def determinant(A): """Returns the determinant of A""" n = A.shape[0] diff --git a/finat/zany.py b/finat/zany.py index ff166beca..b9e1a939d 100644 --- a/finat/zany.py +++ b/finat/zany.py @@ -2,22 +2,38 @@ This module automates the transformation theory of Kirby (2017) and Brubeck & Kirby (2025). Given a FIAT element whose degrees of freedom -are not preserved under push-forward, it constructs the matrix -:math:`V` relating the reference nodes to the push-forwards of the -physical nodes, so that the physical basis functions are obtained as -:math:`M F^*(\\hat\\Psi)` with :math:`M = V^T`. +are not preserved under push-forward, :class:`~finat.physically_mapped. +PhysicallyMappedElement` constructs the matrix :math:`V` relating the +reference nodes to the push-forwards of the physical nodes, so that the +physical basis functions are obtained as :math:`M F^*(\hat\Psi)` with +:math:`M = V^T`. Degrees of freedom are represented symbolically by -:class:`finat.functional.Functional` and processed generically, without -dispatching over FIAT functional types. Each reference node is pulled -back to the physical cell by the chain rule and expanded in the frame -of the physical facet normal and the mapped reference tangents; the -tangential components are derivatives along *mapped* reference tangents -and therefore coincide with reference functionals, whose expansion in -the element's own nodes is a purely numeric generalized Vandermonde -row. In the language of the theory, the frame expansion realizes -:math:`E V^c` and the numeric elimination of the tangential completion -realizes :math:`D`. +:class:`finat.functional.PhysicallyMappedFunctional` and processed +generically, without dispatching over FIAT functional types. This +module supplies the two mapping-specific mixins that plug into the +entity-by-entity assembly loop: + +* :class:`ScalarPhysicallyMappedElement`, for scalar elements with an + affine (identity) pullback -- Morley, Hermite, Argyris, Bell. Each + reference node is pulled back to the physical cell by the chain rule + and expanded in the frame of the physical facet normal and the mapped + reference tangents (:class:`FacetFrame`); the tangential components + are derivatives along *mapped* reference tangents and therefore + coincide with reference functionals, whose expansion in the element's + own nodes is a purely numeric generalized Vandermonde row. + +* :class:`PiolaPhysicallyMappedElement`, for vector- or tensor-valued + elements under the (double) contravariant Piola pullback -- + Mardal-Tai-Winther, Johnson-Mercier, Guzman-Neilan. The roles of the + normal and tangential directions are mirrored: the scaled facet + normal is the cofactor image of the reference one, so pure + normal-component moments are invariant, while scaled tangents map by + the Jacobian. + +In the language of the theory, the frame expansion in either mixin +realizes :math:`E V^c`, and the numeric elimination of the tangential +(respectively normal) completion realizes :math:`D`. """ from functools import reduce @@ -26,27 +42,17 @@ import numpy from FIAT.finite_element import FiniteElement -from gem import Literal, ListTensor, Node, Power, Zero -from finat.functional import Functional -from finat.physically_mapped import (PhysicalGeometry, adjugate, - determinant, identity) +from gem import Literal, Node, Power, Zero +from finat.physically_mapped import PhysicallyMappedElement, adjugate, determinant def generalized_cross(tangents) -> numpy.ndarray: r"""Generalized cross product of d-1 vectors in d dimensions. - Parameters - ---------- - tangents : - A (d-1, d) array of vectors, with numeric or GEM entries. - - Returns - ------- - numpy.ndarray - The vector :math:`C` such that :math:`C \\cdot w = - \\det([t_1; \\dots; t_{d-1}; w])` for all :math:`w`; it is + :arg tangents: A (d-1, d) array of vectors, with numeric or GEM entries. + :returns: The vector :math:`C` such that :math:`C \cdot w = + \det([t_1; \dots; t_{d-1}; w])` for all :math:`w`; it is orthogonal to every :math:`t_k`. - """ A = numpy.asarray(tangents) d = A.shape[1] @@ -63,24 +69,18 @@ class FacetFrame: r"""Normal/tangential frame of a facet and its push-forward. The reference frame consists of the FIAT facet normal - :math:`\\hat{n}` and the scaled facet tangents :math:`\\hat{t}_k`; + :math:`\hat{n}` and the scaled facet tangents :math:`\hat{t}_k`; the physical frame consists of the physical facet normal and the - mapped tangents :math:`J\\hat{t}_k`. Because FIAT normals are + mapped tangents :math:`J\hat{t}_k`. Because FIAT normals are computed from the tangents by the same formula on the reference and - physical cells, the physical normal is :math:`\\kappa\\, C / \\|C\\|` + physical cells, the physical normal is :math:`\kappa\, C / \|C\|` with :math:`C` the generalized cross product of the mapped tangents - and :math:`\\kappa` a cell-independent constant recovered from the + and :math:`\kappa` a cell-independent constant recovered from the reference data. - Parameters - ---------- - fiat_element : - The FIAT element, providing the reference cell. - entity : - The facet number. - J : - GEM expression for the cell Jacobian. - + :arg fiat_element: The FIAT element, providing the reference cell. + :arg entity: The facet number. + :arg J: GEM expression for the cell Jacobian. """ def __init__(self, fiat_element: FiniteElement, entity: int, J: Node): @@ -111,17 +111,9 @@ def __init__(self, fiat_element: FiniteElement, entity: int, J: Node): def reference_coefficients(self, direction: numpy.ndarray) -> numpy.ndarray: r"""Expand a numeric direction in the reference frame. - Parameters - ---------- - direction : - A numeric direction vector. - - Returns - ------- - numpy.ndarray - Coefficients ``(a, b_1, ..., b_{d-1})`` such that the - direction equals :math:`a\\hat{n} + \\sum_k b_k \\hat{t}_k`. - + :arg direction: A numeric direction vector. + :returns: Coefficients ``(a, b_1, ..., b_{d-1})`` such that the + direction equals :math:`a\hat{n} + \sum_k b_k \hat{t}_k`. """ A = numpy.column_stack([self.normal, *self.tangents]) return numpy.linalg.solve(A, direction) @@ -129,17 +121,9 @@ def reference_coefficients(self, direction: numpy.ndarray) -> numpy.ndarray: def decompose(self, direction: Node) -> list: r"""Expand a GEM direction in the un-normalized physical frame. - Parameters - ---------- - direction : - A GEM direction vector. - - Returns - ------- - list - GEM coefficients ``(x_0, x_1, ..., x_{d-1})`` such that the - direction equals :math:`x_0 C + \\sum_k x_k J\\hat{t}_k`. - + :arg direction: A GEM direction vector. + :returns: GEM coefficients ``(x_0, x_1, ..., x_{d-1})`` such that + the direction equals :math:`x_0 C + \sum_k x_k J\hat{t}_k`. """ sd = self._adjA.shape[0] return [reduce(add, (self._adjA[m, i] * direction[i] @@ -155,127 +139,44 @@ def _weight_ratio(wi: numpy.ndarray, wj: numpy.ndarray, tol: float) -> float: return s -def _conditioning_scaling(V: numpy.ndarray, fiat_element: FiniteElement, - coordinate_mapping: PhysicalGeometry) -> None: - r"""Rescale derivative degrees of freedom by the cell size. +class ScalarPhysicallyMappedElement(PhysicallyMappedElement): + r"""Mixin deriving the basis transformation for a scalar element + with an affine (identity) pullback. - Each physical node of derivative order :math:`m` is redefined with a - factor :math:`h^{-m}`, where :math:`h` averages the cell size over - the vertices of its entity. This is the FInAT convention keeping - the mass matrix well-conditioned; it is consistent across cells - because the scaling only depends on shared entities. + Push-forward invariance and completion follow directly from the + chain rule :math:`\nabla(\hat\psi\circ F) = J^T\hat\nabla\hat\psi + \circ F`: point values pull back unchanged; derivative nodes on a + facet are resolved in the normal/tangential frame of + :class:`FacetFrame`; derivative nodes elsewhere (vertex jets) have + no geometric frame to expand in and instead act as their own + completion group. + """ - Parameters - ---------- - V : - Object array being assembled; columns are rescaled in place. - fiat_element : - The FIAT element. - coordinate_mapping : - Object providing the physical geometry as GEM expressions. + #: If False, physical facet moments are plain integrals rather than + #: the measure-intrinsic integral averages of the reference nodes. + avg = True - """ - # cell_size may be a GEM expression or a numpy array of numbers - h = coordinate_mapping.cell_size() - top = fiat_element.get_reference_element().get_topology() - nodes = fiat_element.dual_basis() - entity_ids = fiat_element.entity_dofs() - for dim in entity_ids: - for entity in entity_ids[dim]: - verts = top[dim][entity] - havg = reduce(add, (h[v] for v in verts)) / len(verts) - for i in entity_ids[dim][entity]: - order = nodes[i].max_deriv_order - if order > 0: - V[:, i] = V[:, i] * havg**(-order) - - -def zany_basis_transformation(fiat_element: FiniteElement, - coordinate_mapping: PhysicalGeometry, - tol: float = 1e-12, avg: bool = True, - ndof: int = None) -> ListTensor: - r"""Compute the basis transformation matrix of a FIAT element. - - Parameters - ---------- - fiat_element : - The FIAT element defined on the reference cell. - coordinate_mapping : - Object providing the physical geometry as GEM expressions. - tol : - Tolerance for detecting zeros in the numeric coefficients. - avg : - If False, physical facet moments are plain integrals rather than - the measure-intrinsic integral averages of the reference nodes, - and their columns are rescaled by the physical facet measure. - ndof : - Optional number of physical degrees of freedom; trailing columns - are discarded, so that constrained elements can drop the basis - functions of their extended element. - - Returns - ------- - gem.ListTensor - The transformation :math:`M = V^T` mapping pulled-back reference - basis functions to physical nodal basis functions. + def _check_mapping(self, fiat_element): + mappings = set(fiat_element.mapping()) + if mappings != {"affine"}: + raise NotImplementedError( + f"{type(self).__name__} expects an affine pullback, not {mappings}.") - """ - ref_el = fiat_element.get_reference_element() - sd = ref_el.get_spatial_dimension() - bary, = ref_el.make_points(sd, 0, sd + 1) - J = coordinate_mapping.jacobian_at(bary) - - nodes = fiat_element.dual_basis() - V = identity(fiat_element.space_dimension()) - - mappings = set(fiat_element.mapping()) - piola = mappings in ({"contravariant piola"}, - {"double contravariant piola"}) - if not piola and mappings != {"affine"}: - raise NotImplementedError(f"Cannot transform mappings {mappings}.") - - processed = set() - entity_ids = fiat_element.entity_dofs() - for dim in sorted(entity_ids): - for entity in sorted(entity_ids[dim]): - ells = {i: Functional.from_fiat(nodes[i]) - for i in entity_ids[dim][entity]} - if piola: - # Interior moments are Piola invariant by construction - processed.update(i for i, ell in ells.items() - if ell.order == 0 and dim == sd) - group = {i: ell for i, ell in ells.items() - if i not in processed} - if not group: - continue - if any(ell.rank == 0 or ell.order > 0 - for ell in group.values()): - raise NotImplementedError( - "Cannot yet Piola-transform this node group.") - if dim == sd - 1: - _piola_facet_rows(V, group, fiat_element, entity, J, - processed, tol) - else: - _piola_point_rows(V, group, J, processed, tol) - continue - # Value functionals are push-forward invariant - processed.update(i for i, ell in ells.items() if ell.order == 0) - group = {i: ell for i, ell in ells.items() if ell.order > 0} - if not group: - continue - if dim == sd - 1: - _facet_rows(V, group, fiat_element, entity, J, processed, - tol, avg) - else: - _point_jet_rows(V, group, J, processed, tol) - - _conditioning_scaling(V, fiat_element, coordinate_mapping) - return ListTensor(V[:, :ndof].T) - - -def _facet_rows(V: numpy.ndarray, group: dict, fiat_element: FiniteElement, - entity: int, J: Node, processed: set, tol: float, - avg: bool = True) -> None: + def _invariant_dofs(self, group, dim, sd): + # Point values pull back exactly; only derivative nodes need work. + return {i for i, ell in group.items() if ell.order == 0} + + def _facet_dof_rows(self, V, group, fiat_element, entity, J, processed): + _scalar_facet_rows(V, group, fiat_element, entity, J, processed, + self.tol, self.avg) + + def _point_dof_rows(self, V, group, fiat_element, J, processed): + _scalar_point_rows(V, group, J, processed, self.tol) + + +def _scalar_facet_rows(V: numpy.ndarray, group: dict, fiat_element: FiniteElement, + entity: int, J: Node, processed: set, tol: float, + avg: bool = True) -> None: r"""Assemble the rows of V for derivative nodes on a facet. Physical facet nodes take their normal component along the physical @@ -285,28 +186,17 @@ def _facet_rows(V: numpy.ndarray, group: dict, fiat_element: FiniteElement, mapped reference tangents, coincide with reference functionals that are eliminated numerically through already assembled rows of V. - Parameters - ---------- - V : - Object array being assembled. - group : - Mapping from node index to symbolic Functional for the - derivative nodes on this facet. - fiat_element : - The FIAT element. - entity : - The facet number. - J : - GEM expression for the cell Jacobian. - processed : - Indices of the already assembled rows; updated in place. - tol : - Tolerance for detecting zeros in the numeric coefficients. - avg : - If False, physical facet moments are plain integrals rather than - integral averages, and their columns are rescaled by the + :arg V: Object array being assembled. + :arg group: Mapping from node index to symbolic PhysicallyMappedFunctional + for the derivative nodes on this facet. + :arg fiat_element: The FIAT element. + :arg entity: The facet number. + :arg J: GEM expression for the cell Jacobian. + :arg processed: Indices of the already assembled rows; updated in place. + :arg tol: Tolerance for detecting zeros in the numeric coefficients. + :arg avg: If False, physical facet moments are plain integrals rather + than integral averages, and their columns are rescaled by the physical facet measure. - """ frame = FacetFrame(fiat_element, entity, J) for i, ell in group.items(): @@ -339,8 +229,8 @@ def _facet_rows(V: numpy.ndarray, group: dict, fiat_element: FiniteElement, processed.add(i) -def _point_jet_rows(V: numpy.ndarray, group: dict, J: Node, - processed: set, tol: float) -> None: +def _scalar_point_rows(V: numpy.ndarray, group: dict, J: Node, + processed: set, tol: float) -> None: r"""Assemble the rows of V for derivative nodes away from facets. Away from facets there is no geometric frame, and physical nodes @@ -349,20 +239,12 @@ def _point_jet_rows(V: numpy.ndarray, group: dict, J: Node, affine-interpolation equivalent case, and each pulled-back node is expanded within the group. - Parameters - ---------- - V : - Object array being assembled. - group : - Mapping from node index to symbolic Functional for the - derivative nodes on this entity. - J : - GEM expression for the cell Jacobian. - processed : - Indices of the already assembled rows; updated in place. - tol : - Tolerance for detecting zeros in the numeric coefficients. - + :arg V: Object array being assembled. + :arg group: Mapping from node index to symbolic PhysicallyMappedFunctional + for the derivative nodes on this entity. + :arg J: GEM expression for the cell Jacobian. + :arg processed: Indices of the already assembled rows; updated in place. + :arg tol: Tolerance for detecting zeros in the numeric coefficients. """ suborders = {} for i, ell in group.items(): @@ -388,41 +270,77 @@ def _point_jet_rows(V: numpy.ndarray, group: dict, J: Node, processed.add(i) +class PiolaPhysicallyMappedElement(PhysicallyMappedElement): + r"""Mixin deriving the basis transformation for a vector- or + tensor-valued element under the (double) contravariant Piola + pullback. + + The roles of the normal and tangential directions are mirrored with + respect to the scalar case: interior value moments are Piola + invariant by construction; value moments on a facet are resolved in + the frame of the scaled facet normal (the cofactor image of the + reference one) and the mapped reference tangents; point evaluations + elsewhere have no geometric frame to expand in and instead act as + their own completion group. + """ + + def _check_mapping(self, fiat_element): + mappings = set(fiat_element.mapping()) + if mappings not in ({"contravariant piola"}, {"double contravariant piola"}): + raise NotImplementedError( + f"{type(self).__name__} expects a (double) contravariant " + f"Piola pullback, not {mappings}.") + + def _invariant_dofs(self, group, dim, sd): + # Interior moments are Piola invariant by construction + return {i for i, ell in group.items() if ell.order == 0 and dim == sd} + + def _facet_dof_rows(self, V, group, fiat_element, entity, J, processed): + _check_piola_group(group) + _piola_facet_rows(V, group, fiat_element, entity, J, processed, self.tol) + + def _point_dof_rows(self, V, group, fiat_element, J, processed): + _check_piola_group(group) + _piola_point_rows(V, group, J, processed, self.tol) + + +def _check_piola_group(group: dict) -> None: + """Validate that a group of non-invariant Piola-mapped nodes are all + value moments with a nonzero value rank. + + :arg group: Mapping from node index to symbolic PhysicallyMappedFunctional. + :raises NotImplementedError: If a node is a scalar weight or carries + a derivative, which the theory does not yet cover. + """ + if any(ell.rank == 0 or ell.order > 0 for ell in group.values()): + raise NotImplementedError("Cannot yet Piola-transform this node group.") + + def _piola_facet_rows(V: numpy.ndarray, group: dict, fiat_element: FiniteElement, entity: int, J: Node, processed: set, tol: float) -> None: r"""Assemble the rows of V for facet moments of Piola-mapped values. - This mirrors :func:`_facet_rows` with the roles of the normal and - tangential directions exchanged: under the contravariant Piola map - the scaled facet normal is the image of the reference one under the - cofactor matrix :math:`K = \operatorname{adj}(J)^T`, so pure normal - moments are invariant, while the scaled tangents map by :math:`J`. - Each node carries a per-point profile of frame coordinates, shared - by its physical counterpart; the pulled-back reference node mixes - the coordinates through the frame expansion of :math:`K\hat{t}_k`, - the tangential profiles are matched within the group, and the - residual normal profile is eliminated numerically through already - assembled rows of V. - - Parameters - ---------- - V : - Object array being assembled. - group : - Mapping from node index to symbolic Functional for the value - moments on this facet. - fiat_element : - The FIAT element. - entity : - The facet number. - J : - GEM expression for the cell Jacobian. - processed : - Indices of the already assembled rows; updated in place. - tol : - Tolerance for detecting zeros in the numeric coefficients. - + This mirrors :func:`_scalar_facet_rows` with the roles of the normal + and tangential directions exchanged: under the contravariant Piola + map the scaled facet normal is the image of the reference one under + the cofactor matrix :math:`K = \operatorname{adj}(J)^T`, so pure + normal moments are invariant, while the scaled tangents map by + :math:`J`. Each node carries a per-point profile of frame + coordinates, shared by its physical counterpart; the pulled-back + reference node mixes the coordinates through the frame expansion of + :math:`K\hat{t}_k`, the tangential profiles are matched within the + group, and the residual normal profile is eliminated numerically + through already assembled rows of V. + + :arg V: Object array being assembled. + :arg group: Mapping from node index to symbolic PhysicallyMappedFunctional + for the value moments on this facet. + :arg fiat_element: The FIAT element. + :arg entity: The facet number. + :arg J: GEM expression for the cell Jacobian. + :arg processed: Indices of the already assembled rows; updated in place. + :arg tol: Tolerance for detecting zeros in the numeric coefficients. """ ref_el = fiat_element.get_reference_element() sd = ref_el.get_spatial_dimension() @@ -518,26 +436,18 @@ def _piola_point_rows(V: numpy.ndarray, group: dict, J: Node, processed: set, tol: float) -> None: r"""Assemble the rows of V for point values of Piola-mapped fields. - This mirrors :func:`_point_jet_rows`: away from facets, physical + This mirrors :func:`_scalar_point_rows`: away from facets, physical point evaluations keep the reference (Cartesian) components, which - pull back through the cofactor matrix :math:`K = \mathrm{adj}(J)^T` + pull back through the cofactor matrix :math:`K = \operatorname{adj}(J)^T` of the contravariant Piola map, so the group of components at each point acts as its own completion. - Parameters - ---------- - V : - Object array being assembled. - group : - Mapping from node index to symbolic Functional for the value - nodes on this entity. - J : - GEM expression for the cell Jacobian. - processed : - Indices of the already assembled rows; updated in place. - tol : - Tolerance for detecting zeros in the numeric coefficients. - + :arg V: Object array being assembled. + :arg group: Mapping from node index to symbolic PhysicallyMappedFunctional + for the value nodes on this entity. + :arg J: GEM expression for the cell Jacobian. + :arg processed: Indices of the already assembled rows; updated in place. + :arg tol: Tolerance for detecting zeros in the numeric coefficients. """ sd = J.shape[0] Jnp = numpy.array([[J[i, k] for k in range(sd)] for i in range(sd)], diff --git a/test/finat/test_zany_automation.py b/test/finat/test_zany_automation.py index 40fa52d19..e68f74ca2 100644 --- a/test/finat/test_zany_automation.py +++ b/test/finat/test_zany_automation.py @@ -1,36 +1,38 @@ import FIAT +import finat import numpy as np import pytest from gem.interpreter import evaluate -from finat.functional import Functional -from finat.zany import zany_basis_transformation +from finat.functional import PhysicallyMappedFunctional +from finat.fiat_elements import FiatElement +from finat.zany import PiolaPhysicallyMappedElement, ScalarPhysicallyMappedElement @pytest.mark.parametrize("dimension", [2, 3]) def test_functional_from_fiat(dimension): - """The symbolic Functional recovers order, weights and direction - numerically from the FIAT functional dictionaries.""" + """The symbolic PhysicallyMappedFunctional recovers order, weights and + direction numerically from the FIAT functional dictionaries.""" cell = FIAT.ufc_simplex(dimension) element = FIAT.Morley(cell) entity_ids = element.entity_dofs() nodes = element.dual_basis() for i in entity_ids[dimension - 2][0]: - ell = Functional.from_fiat(nodes[i]) + ell = PhysicallyMappedFunctional.from_fiat(nodes[i]) assert ell.order == 0 assert ell.direction is None for entity in entity_ids[dimension - 1]: for i in entity_ids[dimension - 1][entity]: - ell = Functional.from_fiat(nodes[i]) + ell = PhysicallyMappedFunctional.from_fiat(nodes[i]) assert ell.order == 1 normal = cell.compute_normal(entity) cosine = ell.direction @ normal assert np.isclose(abs(cosine), np.linalg.norm(normal)) -auto_elements = [FIAT.Morley, FIAT.CubicHermite] +auto_elements = [finat.Morley, finat.Hermite] @pytest.mark.parametrize("element", auto_elements) @@ -41,21 +43,37 @@ def test_conditioning_scaling(ref_to_phys, scaled_ref_to_phys, element, dimensio scaled = scaled_ref_to_phys[dimension][-1] # the same geometric mapping with unit cell size unit = type(ref_to_phys[dimension])(scaled.ref_cell, scaled.phys_cell) - fiat_element = element(scaled.ref_cell) + finat_element = element(scaled.ref_cell) - Ms = evaluate([zany_basis_transformation(fiat_element, scaled)])[0].arr - Mu = evaluate([zany_basis_transformation(fiat_element, unit)])[0].arr + Ms = evaluate([finat_element.basis_transformation(scaled)])[0].arr + Mu = evaluate([finat_element.basis_transformation(unit)])[0].arr h = scaled.cell_size()[0] assert not np.isclose(h, 1) - orders = [node.max_deriv_order for node in fiat_element.dual_basis()] + orders = [node.max_deriv_order for node in finat_element._element.dual_basis()] expected = Mu * np.asarray([h**-order for order in orders])[:, None] assert np.allclose(Ms, expected) -def test_unsupported_nodes(ref_to_phys): - """Covariant elements are not handled yet.""" +def test_unsupported_mapping_scalar(ref_to_phys): + """A ScalarPhysicallyMappedElement rejects a non-affine FIAT element.""" mapping = ref_to_phys[2] - element = FIAT.Nedelec(mapping.ref_cell, 1) + + class BogusScalar(ScalarPhysicallyMappedElement, FiatElement): + pass + + element = BogusScalar(FIAT.Nedelec(mapping.ref_cell, 1)) + with pytest.raises(NotImplementedError): + element.basis_transformation(mapping) + + +def test_unsupported_mapping_piola(ref_to_phys): + """A PiolaPhysicallyMappedElement rejects a covariant FIAT element.""" + mapping = ref_to_phys[2] + + class BogusPiola(PiolaPhysicallyMappedElement, FiatElement): + pass + + element = BogusPiola(FIAT.Nedelec(mapping.ref_cell, 1)) with pytest.raises(NotImplementedError): - zany_basis_transformation(element, mapping) + element.basis_transformation(mapping) From 76463d4258019bdb3dda56e1b796a754540435e2 Mon Sep 17 00:00:00 2001 From: Pablo Brubeck Date: Mon, 13 Jul 2026 17:04:42 +0100 Subject: [PATCH 13/34] Move the automatic-transformation loop body into a Zany mixin PhysicallyMappedElement (physically_mapped.py) is restored to its original shape: a generic, abstract mixin with no knowledge of the zany theory, exactly as used by hand-coded elements (AW, HCT, PowellSabin, Walkington, ...). It no longer imports finat.functional. The template-method loop and its four hooks move into a new ZanyPhysicallyMappedElement(PhysicallyMappedElement) in finat/zany.py, which ScalarPhysicallyMappedElement and PiolaPhysicallyMappedElement now subclass instead of PhysicallyMappedElement directly. This keeps all zany-specific machinery -- the loop, the hooks, the pure math functions, and the symbolic PhysicallyMappedFunctional dependency -- contained in finat/zany.py and finat/functional.py, with physically_mapped.py staying general-purpose infrastructure. No behavioral change: full finat suite, flake8, and pydocstyle pass unchanged. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 29 ++++--- finat/physically_mapped.py | 150 ++------------------------------- finat/zany.py | 164 ++++++++++++++++++++++++++++++++++--- 3 files changed, 178 insertions(+), 165 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b50ccb519..baef4d5e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -166,12 +166,15 @@ $V = E V^c D$ directly from a FIAT element's dual basis. The implementation must the theory factor by factor, not merely reproduce matrix entries: **Status (2026-07-13).** The symbolic dof lives in `finat/functional.py` as -`finat.PhysicallyMappedFunctional`. The OOP structure is a template method: the -entity-by-entity assembly loop is implemented once, as the concrete -`PhysicallyMappedElement.basis_transformation` in `finat/physically_mapped.py`, calling -four hooks (`_check_mapping`, `_invariant_dofs`, `_facet_dof_rows`, `_point_dof_rows`) -that carry ALL mapping-specific knowledge — the loop itself contains no `if piola` -anywhere. `finat/zany.py` supplies the two mixins implementing those hooks, +`finat.PhysicallyMappedFunctional`. `finat/physically_mapped.py` stays fully generic: +`PhysicallyMappedElement` is unchanged from before this project, still an abstract +mixin with no knowledge of the zany theory, used as-is by hand-coded elements (AW, +HCT, PowellSabin, Walkington, ...). All the automation lives in `finat/zany.py`, as a +template method on `ZanyPhysicallyMappedElement(PhysicallyMappedElement)`: the +entity-by-entity assembly loop is implemented once, in its concrete +`basis_transformation`, calling four hooks (`_check_mapping`, `_invariant_dofs`, +`_facet_dof_rows`, `_point_dof_rows`) that carry ALL mapping-specific knowledge — the +loop itself contains no `if piola` anywhere. Two mixins implement those hooks, `ScalarPhysicallyMappedElement` (affine pullback: Morley, Hermite, Argyris, Bell) and `PiolaPhysicallyMappedElement` ((double) contravariant Piola: MTW, Johnson-Mercier, Guzman-Neilan), plus the pure math functions they call (`FacetFrame`, @@ -179,12 +182,14 @@ Guzman-Neilan), plus the pure math functions they call (`FacetFrame`, these take plain arrays/GEM expressions, no `self`, so the mathematics stays readable independent of the class plumbing. Concrete elements (`finat.Morley`, etc.) are now just a citation plus a FIAT constructor call: mixing in the right base class is enough, -`basis_transformation` is inherited. `ndof` truncation is no longer a parameter; the -loop always slices by `self.space_dimension()`, which constrained elements (Bell, GN) -already override. Tests: `test/finat/test_zany_automation.py`; `check_zany_mapping` -lives in the finat conftest and is provided to test modules as a pytest fixture (pytest -runs with `--import-mode=importlib`, so test modules cannot import from each other or -from conftest). +`basis_transformation` is inherited (MRO example: `Morley -> ScalarPhysicallyMappedElement +-> ZanyPhysicallyMappedElement -> PhysicallyMappedElement -> ...`). `ndof` truncation is +no longer a parameter; the loop always slices by `self.space_dimension()`, which +constrained elements (Bell, GN) already override. Tests: +`test/finat/test_zany_automation.py`; `check_zany_mapping` lives in the finat conftest +and is provided to test modules as a pytest fixture (pytest runs with +`--import-mode=importlib`, so test modules cannot import from each other or from +conftest). **Framework design.** A dof is a symbolic `finat.PhysicallyMappedFunctional`: $\ell(f) = \sum_q w_q \langle D, \nabla^m f(x_q)\rangle$ with numeric points/weights diff --git a/finat/physically_mapped.py b/finat/physically_mapped.py index 66e59b9cd..aef6f581d 100644 --- a/finat/physically_mapped.py +++ b/finat/physically_mapped.py @@ -1,13 +1,10 @@ from abc import ABCMeta, abstractmethod from collections.abc import Mapping -from functools import reduce -from operator import add import gem import numpy from finat.citations import cite -from finat.functional import PhysicallyMappedFunctional class NeedsCoordinateMappingElement(metaclass=ABCMeta): @@ -73,124 +70,24 @@ class PhysicallyMappedElement(NeedsCoordinateMappingElement): Concrete elements either implement :meth:`basis_transformation` entirely by hand, or derive it automatically by mixing in :class:`~finat.zany.ScalarPhysicallyMappedElement` or - :class:`~finat.zany.PiolaPhysicallyMappedElement`, which supply the - four hooks below and inherit the entity-by-entity assembly loop - implemented here. + :class:`~finat.zany.PiolaPhysicallyMappedElement`, which (via + :class:`~finat.zany.ZanyPhysicallyMappedElement`) supply the + entity-by-entity assembly loop of Kirby (2017) and + Brubeck & Kirby (2025). """ - #: Numerical tolerance used throughout automatic basis transformation - #: to detect vanishing coefficients. - tol = 1e-12 - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) cite("Kirby2018zany") cite("Kirby2019zany") self.restriction_indices = None + @abstractmethod def basis_transformation(self, coordinate_mapping): - r"""Assemble the basis transformation :math:`M = V^T`. - - Following the factorization :math:`V = E V^c D` of Kirby (2017) - and Brubeck & Kirby (2025), the matrix :math:`V` relating the - reference nodes to the push-forwards of the physical nodes is - assembled one topological entity at a time, in increasing - dimension, so that the completion of a node on an entity can - always be resolved against the already-assembled rows of - lower-dimensional entities. - - On each entity, nodes that are already push-forward invariant - (:meth:`_invariant_dofs`) contribute an identity row for free, - since :math:`V` starts out as the identity. The rest are - assembled by :meth:`_facet_dof_rows` (on a codimension-1 - entity) or :meth:`_point_dof_rows` (elsewhere); these three - hooks, together with :meth:`_check_mapping`, encode the - mapping-specific (affine or Piola) part of the theory, and this - method contains no knowledge of which mapping is in play. - - :arg coordinate_mapping: Object providing physical geometry. - """ - fiat_element = self._element - self._check_mapping(fiat_element) - - ref_el = fiat_element.get_reference_element() - sd = ref_el.get_spatial_dimension() - bary, = ref_el.make_points(sd, 0, sd + 1) - J = coordinate_mapping.jacobian_at(bary) - - nodes = fiat_element.dual_basis() - V = identity(fiat_element.space_dimension()) - - processed = set() - entity_ids = fiat_element.entity_dofs() - for dim in sorted(entity_ids): - for entity in sorted(entity_ids[dim]): - group = {i: PhysicallyMappedFunctional.from_fiat(nodes[i]) - for i in entity_ids[dim][entity]} - invariant = self._invariant_dofs(group, dim, sd) - processed.update(invariant) - group = {i: ell for i, ell in group.items() if i not in invariant} - if not group: - continue - if dim == sd - 1: - self._facet_dof_rows(V, group, fiat_element, entity, J, processed) - else: - self._point_dof_rows(V, group, fiat_element, J, processed) - - _rescale_derivative_dofs(V, fiat_element, coordinate_mapping) - ndof = self.space_dimension() - return gem.ListTensor(V[:, :ndof].T) - - def _check_mapping(self, fiat_element): - """Verify that this class knows how to transform this element's pullback. - - :arg fiat_element: The FIAT element defined on the reference cell. - :raises NotImplementedError: If the pullback is not supported. - """ - raise NotImplementedError( - f"{type(self).__name__} does not implement automatic basis transformation.") - - def _invariant_dofs(self, group, dim, sd): - """Select the nodes of an entity that are already push-forward invariant. - - :arg group: Dict mapping node index to :class:`PhysicallyMappedFunctional` - for the reference nodes associated with one entity. - :arg dim: Topological dimension of the entity. - :arg sd: Spatial dimension of the cell. - :returns: The subset of ``group`` keys whose row of :math:`V` is - the identity row. - """ - raise NotImplementedError( - f"{type(self).__name__} does not implement automatic basis transformation.") - - def _facet_dof_rows(self, V, group, fiat_element, entity, J, processed): - """Assemble the rows of V for the non-invariant nodes on a facet. - - :arg V: Object array being assembled; rows are set in place. - :arg group: Dict mapping node index to :class:`PhysicallyMappedFunctional` - for the non-invariant reference nodes on this facet. - :arg fiat_element: The FIAT element defined on the reference cell. - :arg entity: The facet number. - :arg J: GEM expression for the cell Jacobian. - :arg processed: Indices of the already assembled rows of ``V``; - updated in place. - """ - raise NotImplementedError( - f"{type(self).__name__} does not implement automatic basis transformation.") - - def _point_dof_rows(self, V, group, fiat_element, J, processed): - """Assemble the rows of V for the non-invariant nodes away from a facet. - - :arg V: Object array being assembled; rows are set in place. - :arg group: Dict mapping node index to :class:`PhysicallyMappedFunctional` - for the non-invariant reference nodes on this entity. - :arg fiat_element: The FIAT element defined on the reference cell. - :arg J: GEM expression for the cell Jacobian. - :arg processed: Indices of the already assembled rows of ``V``; - updated in place. - """ - raise NotImplementedError( - f"{type(self).__name__} does not implement automatic basis transformation.") + """Transformation matrix for the basis functions. + + :arg coordinate_mapping: Object providing physical geometry.""" + pass def map_tabulation(self, ref_tabulation, coordinate_mapping): assert coordinate_mapping is not None @@ -318,35 +215,6 @@ def identity(*shape): return V -def _rescale_derivative_dofs(V, fiat_element, coordinate_mapping): - r"""Rescale derivative degrees of freedom by the cell size. - - Each physical node of derivative order :math:`m` is redefined with a - factor :math:`h^{-m}`, where :math:`h` averages the cell size over - the vertices of its entity. This is the FInAT convention keeping - the mass matrix well-conditioned; it is consistent across cells - because the scaling only depends on shared entities. - - :arg V: Object array being assembled; columns are rescaled in place. - :arg fiat_element: The FIAT element defined on the reference cell. - :arg coordinate_mapping: Object providing the physical geometry as - GEM expressions. - """ - # cell_size may be a GEM expression or a numpy array of numbers - h = coordinate_mapping.cell_size() - top = fiat_element.get_reference_element().get_topology() - nodes = fiat_element.dual_basis() - entity_ids = fiat_element.entity_dofs() - for dim in entity_ids: - for entity in entity_ids[dim]: - verts = top[dim][entity] - havg = reduce(add, (h[v] for v in verts)) / len(verts) - for i in entity_ids[dim][entity]: - order = nodes[i].max_deriv_order - if order > 0: - V[:, i] = V[:, i] * havg**(-order) - - def determinant(A): """Returns the determinant of A""" n = A.shape[0] diff --git a/finat/zany.py b/finat/zany.py index b9e1a939d..4ba0b3e26 100644 --- a/finat/zany.py +++ b/finat/zany.py @@ -2,17 +2,17 @@ This module automates the transformation theory of Kirby (2017) and Brubeck & Kirby (2025). Given a FIAT element whose degrees of freedom -are not preserved under push-forward, :class:`~finat.physically_mapped. -PhysicallyMappedElement` constructs the matrix :math:`V` relating the -reference nodes to the push-forwards of the physical nodes, so that the -physical basis functions are obtained as :math:`M F^*(\hat\Psi)` with -:math:`M = V^T`. +are not preserved under push-forward, :class:`ZanyPhysicallyMappedElement` +constructs the matrix :math:`V` relating the reference nodes to the +push-forwards of the physical nodes, so that the physical basis +functions are obtained as :math:`M F^*(\hat\Psi)` with :math:`M = V^T`. Degrees of freedom are represented symbolically by :class:`finat.functional.PhysicallyMappedFunctional` and processed -generically, without dispatching over FIAT functional types. This -module supplies the two mapping-specific mixins that plug into the -entity-by-entity assembly loop: +generically, without dispatching over FIAT functional types. +:class:`ZanyPhysicallyMappedElement` implements the entity-by-entity +assembly loop once, calling four hooks that carry all mapping-specific +knowledge; this module supplies the two mixins implementing them: * :class:`ScalarPhysicallyMappedElement`, for scalar elements with an affine (identity) pullback -- Morley, Hermite, Argyris, Bell. Each @@ -42,8 +42,9 @@ import numpy from FIAT.finite_element import FiniteElement -from gem import Literal, Node, Power, Zero -from finat.physically_mapped import PhysicallyMappedElement, adjugate, determinant +from gem import Literal, ListTensor, Node, Power, Zero +from finat.functional import PhysicallyMappedFunctional +from finat.physically_mapped import PhysicallyMappedElement, adjugate, determinant, identity def generalized_cross(tangents) -> numpy.ndarray: @@ -139,7 +140,146 @@ def _weight_ratio(wi: numpy.ndarray, wj: numpy.ndarray, tol: float) -> float: return s -class ScalarPhysicallyMappedElement(PhysicallyMappedElement): +class ZanyPhysicallyMappedElement(PhysicallyMappedElement): + r"""Mixin implementing the entity-by-entity assembly loop shared by + :class:`ScalarPhysicallyMappedElement` and + :class:`PiolaPhysicallyMappedElement`. + + Following the factorization :math:`V = E V^c D` of Kirby (2017) and + Brubeck & Kirby (2025), the matrix :math:`V` relating the reference + nodes to the push-forwards of the physical nodes is assembled one + topological entity at a time, in increasing dimension, so that the + completion of a node on an entity can always be resolved against + the already-assembled rows of lower-dimensional entities. + + On each entity, nodes that are already push-forward invariant + (:meth:`_invariant_dofs`) contribute an identity row for free, + since :math:`V` starts out as the identity. The rest are assembled + by :meth:`_facet_dof_rows` (on a codimension-1 entity) or + :meth:`_point_dof_rows` (elsewhere); these hooks, together with + :meth:`_check_mapping`, encode the mapping-specific (affine or + Piola) part of the theory, and :meth:`basis_transformation` itself + contains no knowledge of which mapping is in play. + """ + + #: Numerical tolerance used throughout automatic basis transformation + #: to detect vanishing coefficients. + tol = 1e-12 + + def basis_transformation(self, coordinate_mapping) -> ListTensor: + fiat_element = self._element + self._check_mapping(fiat_element) + + ref_el = fiat_element.get_reference_element() + sd = ref_el.get_spatial_dimension() + bary, = ref_el.make_points(sd, 0, sd + 1) + J = coordinate_mapping.jacobian_at(bary) + + nodes = fiat_element.dual_basis() + V = identity(fiat_element.space_dimension()) + + processed = set() + entity_ids = fiat_element.entity_dofs() + for dim in sorted(entity_ids): + for entity in sorted(entity_ids[dim]): + group = {i: PhysicallyMappedFunctional.from_fiat(nodes[i]) + for i in entity_ids[dim][entity]} + invariant = self._invariant_dofs(group, dim, sd) + processed.update(invariant) + group = {i: ell for i, ell in group.items() if i not in invariant} + if not group: + continue + if dim == sd - 1: + self._facet_dof_rows(V, group, fiat_element, entity, J, processed) + else: + self._point_dof_rows(V, group, fiat_element, J, processed) + + _rescale_derivative_dofs(V, fiat_element, coordinate_mapping) + ndof = self.space_dimension() + return ListTensor(V[:, :ndof].T) + + def _check_mapping(self, fiat_element): + """Verify that this class knows how to transform this element's pullback. + + :arg fiat_element: The FIAT element defined on the reference cell. + :raises NotImplementedError: If the pullback is not supported. + """ + raise NotImplementedError( + f"{type(self).__name__} does not implement automatic basis transformation.") + + def _invariant_dofs(self, group, dim, sd): + """Select the nodes of an entity that are already push-forward invariant. + + :arg group: Dict mapping node index to :class:`PhysicallyMappedFunctional` + for the reference nodes associated with one entity. + :arg dim: Topological dimension of the entity. + :arg sd: Spatial dimension of the cell. + :returns: The subset of ``group`` keys whose row of :math:`V` is + the identity row. + """ + raise NotImplementedError( + f"{type(self).__name__} does not implement automatic basis transformation.") + + def _facet_dof_rows(self, V, group, fiat_element, entity, J, processed): + """Assemble the rows of V for the non-invariant nodes on a facet. + + :arg V: Object array being assembled; rows are set in place. + :arg group: Dict mapping node index to :class:`PhysicallyMappedFunctional` + for the non-invariant reference nodes on this facet. + :arg fiat_element: The FIAT element defined on the reference cell. + :arg entity: The facet number. + :arg J: GEM expression for the cell Jacobian. + :arg processed: Indices of the already assembled rows of ``V``; + updated in place. + """ + raise NotImplementedError( + f"{type(self).__name__} does not implement automatic basis transformation.") + + def _point_dof_rows(self, V, group, fiat_element, J, processed): + """Assemble the rows of V for the non-invariant nodes away from a facet. + + :arg V: Object array being assembled; rows are set in place. + :arg group: Dict mapping node index to :class:`PhysicallyMappedFunctional` + for the non-invariant reference nodes on this entity. + :arg fiat_element: The FIAT element defined on the reference cell. + :arg J: GEM expression for the cell Jacobian. + :arg processed: Indices of the already assembled rows of ``V``; + updated in place. + """ + raise NotImplementedError( + f"{type(self).__name__} does not implement automatic basis transformation.") + + +def _rescale_derivative_dofs(V, fiat_element, coordinate_mapping): + r"""Rescale derivative degrees of freedom by the cell size. + + Each physical node of derivative order :math:`m` is redefined with a + factor :math:`h^{-m}`, where :math:`h` averages the cell size over + the vertices of its entity. This is the FInAT convention keeping + the mass matrix well-conditioned; it is consistent across cells + because the scaling only depends on shared entities. + + :arg V: Object array being assembled; columns are rescaled in place. + :arg fiat_element: The FIAT element defined on the reference cell. + :arg coordinate_mapping: Object providing the physical geometry as + GEM expressions. + """ + # cell_size may be a GEM expression or a numpy array of numbers + h = coordinate_mapping.cell_size() + top = fiat_element.get_reference_element().get_topology() + nodes = fiat_element.dual_basis() + entity_ids = fiat_element.entity_dofs() + for dim in entity_ids: + for entity in entity_ids[dim]: + verts = top[dim][entity] + havg = reduce(add, (h[v] for v in verts)) / len(verts) + for i in entity_ids[dim][entity]: + order = nodes[i].max_deriv_order + if order > 0: + V[:, i] = V[:, i] * havg**(-order) + + +class ScalarPhysicallyMappedElement(ZanyPhysicallyMappedElement): r"""Mixin deriving the basis transformation for a scalar element with an affine (identity) pullback. @@ -270,7 +410,7 @@ def _scalar_point_rows(V: numpy.ndarray, group: dict, J: Node, processed.add(i) -class PiolaPhysicallyMappedElement(PhysicallyMappedElement): +class PiolaPhysicallyMappedElement(ZanyPhysicallyMappedElement): r"""Mixin deriving the basis transformation for a vector- or tensor-valued element under the (double) contravariant Piola pullback. From f6a0b6753b3e0f88e1ef3a6f9304316621b13064 Mon Sep 17 00:00:00 2001 From: Pablo Brubeck Date: Mon, 13 Jul 2026 17:37:39 +0100 Subject: [PATCH 14/34] Rewrite Pattern Matching section with lessons from the zany automation Replace the pre-project rough draft with concrete, grounded strategies: - Mathematical structures: the five-number dof shape independent of FIAT class, pullback as a uniform tensor-slot contraction (J for derivatives, K = adj(J)^T for Piola values), frame decomposition as the one shared computational primitive, extended elements as restriction. - Confusing mathematical ideas clarified: the papers' vs FInAT's Jacobian direction, the reciprocal-basis contragredient transformation that only shows up in 3D Piola elements (not in any of the papers), and the sign/scale invariance required of formulas built from numerically-recovered (SVD) invariants. - Confusing GEM patterns clarified: operator overloading vs manual node construction, adjugate/determinant as the symbolic matrix inverse, numpy.full with Zero() for sparse GEM arrays, the identity-then-mutate assembly idiom, and the row/column/transpose convention. - Design strategies that generalize: duality-first design, recovering mathematical type from data instead of class hierarchies, template method for mapping-specific case-splits, validating against independently computed ground truth, and incremental generalization from one worked example at a time. - A dedicated subsection on working with the human collaborator: the literature is necessary but not sufficient (the reciprocal-basis fix came from the user, not the papers), expecting and re-verifying after mid-project architectural corrections, and keeping the *why* on record for future sessions. Also fixes a stale pointer: morley_transform now lives in finat/walkington.py, not finat/morley.py (moved in an earlier commit when Morley itself was automated). Co-Authored-By: Claude Fable 5 --- AGENTS.md | 193 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 163 insertions(+), 30 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index baef4d5e5..029f3bdd3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,35 +71,167 @@ Agents modifying FIAT code must follow these fundamental development principles: ## Pattern Matching and Mathematical Reasoning -When designing or debugging FIAT, FInAT, and GEM changes, use the existing codebase as a library of -mathematical patterns rather than starting from ad hoc special cases: - -* Match new element constructions against the nearest existing family with the same structural - decomposition. Tensor-product, restricted, physically mapped, and enriched elements usually share - a factorization pattern that should be reused explicitly. -* When a feature seems to require a special case, test whether the same mathematics already appears in - another element family or mapping path. The right answer is often a more general basis - transformation, not a new branch. -* Separate reference-space reasoning from physical-space reasoning. In FIAT and FInAT, derive basis - transformations from the element map and continuity requirements first, then encode that structure - in GEM expressions. -* Treat tensor-product spaces as tensor-product mathematics. Look for Kronecker-style factorization - in basis matrices, coordinate mappings, and dual evaluations before introducing custom assembly - logic. -* For extruded or vertically constant factors, identify the dimension that is geometrically active - and the dimension that is algebraically passive. The passive factor should usually contribute a - simple constant, identity, or lower-dimensional pullback rather than a new geometric rule. -* Debug by matching the failing object against a known neighboring case: compare the cell, element - family, mapping type, continuity class, and tensor structure before changing code. -* In GEM, inspect whether an expression should factor, broadcast, or propagate a coordinate mapping. - If the expression is not matching the expected shape, the bug is often in the way indices or - subexpressions are assembled, not in the downstream optimizer. -* Use the mathematical continuity target as a design constraint. For finite elements, ask what - inter-element continuity the space must satisfy, then derive the local basis and transformation - rules from that requirement. -* Prefer proofs by structure over proofs by example. A construction is correct when the pullback, - restriction, and tensor-product algebra agree with the element's continuity and approximation - properties, not when one or two test cases happen to pass. +This section used to be a rough draft of aspirations. It is now grounded in the concrete +experience of automating the Kirby (2017) / Aznaran-Kirby-Farrell (2022) / Brubeck & Kirby +(2025) transformation theory (`finat/zany.py`, `finat/functional.py`) for Morley, Hermite, +Argyris, Bell, Mardal-Tai-Winther, Johnson-Mercier, and Guzman-Neilan. The lessons below are +about *how to design and debug this kind of code*, not just about this one project. + +### Mathematical structures to recognize in FIAT/FInAT + +* **A degree of freedom is fully described by five numbers, not by its FIAT class.** Every + functional FIAT builds from `pt_dict`/`deriv_dict` reduces to (points, weights, derivative + order $m$, a direction tensor of rank $m$, a value rank for vector/tensor-valued dofs). + `IntegralMomentOfNormalDerivative`, `PointNormalDerivative`, `TensorBidirectionalIntegralMoment`, + etc. are just different ways of *constructing* the same five numbers. Recognizing this + collapses "N functional types to support" into "one shape to recover numerically" + (`finat.PhysicallyMappedFunctional.from_fiat`, `finat/functional.py`). +* **Pullback is always "contract each tensor slot of the direction with a fixed matrix."** + Order-0 (values) are invariant (zero slots to contract). Order-$m$ derivatives contract $m$ + slots with the Jacobian $J$ (the chain rule). Rank-$r$ Piola values contract $r$ slots with + the cofactor matrix $K = \operatorname{adj}(J)^T$. This is *the same operation* with a + different matrix, which is why the scalar and Piola code in `finat/zany.py` are mirror images + of each other (`_scalar_point_rows` / `_piola_point_rows`, `_scalar_facet_rows` / + `_piola_facet_rows`) rather than unrelated implementations. +* **Frame decomposition is the one computational primitive underlying every non-affine + element.** Facet dofs (Morley/Argyris/Bell normal derivatives; MTW/JM/GN normal-tangential + moments) and vertex-jet completions (Hermite/Argyris/Bell gradients and Hessians) are all + solved the *same* way: split a direction/profile into an invariant part and a part that needs + completing, express the pulled-back quantity in the frame built from the mapped generators of + that split (`FacetFrame`, or the direction-basis inverse in `_scalar_point_rows`), and solve + symbolically via `adjugate`/`determinant`. Once this pattern is visible, "add a new element" + stops being "derive new math" and becomes "which invariant subspace, which frame." +* **Constrained/extended elements are the same construction as a restriction.** Bell and + Guzman-Neilan are both "take the extended FIAT element with its constraint functionals as + extra dofs, transform the whole thing, then keep only the first $\nu$ columns." This is + Kirby (2017) §5's extended-element proposition, and in code it is nothing more than + `space_dimension()` returning a smaller count than the FIAT element's — no special-casing + needed in the transformation loop itself. + +### Confusing mathematical ideas, clarified + +* **The Jacobian direction is flipped between the papers and the code.** The papers define + $F$ from physical to reference space, so their $J$ is FInAT's Jacobian *inverse*. + `coordinate_mapping.jacobian_at(point)` returns $\partial x_{\text{phys}}/\partial + x_{\text{ref}}$ (see "FInAT implementation conventions" below); a paper's $J^{-T}$ is FInAT's + plain Jacobian, transposed. This is a constant source of "why does my formula look + transposed" confusion — check this convention flip before suspecting a sign or index bug. +* **Components against a reciprocal/dual basis transform contragrediently — this is the single + subtlety that broke 3D and is not in any of the papers.** FIAT builds the tangential + component of 3D Piola-mapped dofs (MTW) using `cross(n, t_k)`, the *reciprocal* partner of + the tangent frame, not the tangent frame itself. A general fact from differential geometry: + if a frame transforms by a matrix $A$, components expressed against its *reciprocal* frame + transform by $A^{-T}$ (up to a determinant factor), not by $A$. Kirby (2017)/Aznaran-Kirby- + Farrell (2022) only work out the 2D case, where the tangent "plane" is 1-dimensional and this + correction $S^{-1}$ collapses to $1$ — silently hiding the effect. **Whenever a FIAT dual set + builds a direction via a cross product against the normal (a common way to get "the other + in-plane directions" in 3D), check whether its transformation law is contragredient before + assuming it matches the direct frame.** +* **Numerically recovered invariants (SVD, pinv) are only defined up to a group action, and + formulas built from them must be invariant under that action.** `from_fiat`'s SVD recovers a + direction/weight pair $(\hat n, w)$ up to a joint sign flip ($\hat n \to -\hat n$, $w \to + -w$ leaves the functional unchanged). A formula like $r_k = a x_k + (1-c)\beta_k$ that + implicitly assumes a particular sign will be wrong on exactly the subset of entities where + SVD happened to pick the other sign — a bug that looks like it "mostly works" and is + otherwise very hard to localize. The fix, $r_k = x_k - c\beta_k$, is invariant under the + joint flip. **Always test the full topology of a non-degenerate, non-symmetric physical + cell** (see `test/finat/conftest.py::MyMapping`'s deliberately irregular vertex coordinates) — + a symmetric test cell can accidentally hide a sign bug that only shows up on a generic mesh. + +### Confusing GEM patterns, clarified + +* **Use operator overloading, not manual node construction.** `gem.Node` overloads + `+ - * / ** @` (via `as_gem`/`componentwise`) to work transparently across GEM nodes, + `gem.Literal`, and plain Python/numpy numbers, with automatic `Zero` folding. Write + `havg**(-m)`, never `gem.Power(havg, gem.Literal(-m))`: the operator form also works when + `havg` is a raw float (as in the test harness's `MyMapping.cell_size()`) whereas a manual + `gem.Power` call assumes GEM operands and will not always coerce correctly. +* **Never call `numpy.linalg.inv`/`solve` on a GEM-valued matrix.** LAPACK cannot see inside + GEM expressions. Symbolic linear solves use `adjugate(A) / determinant(A)` + (`finat/physically_mapped.py`) — the symbolic Cramer's-rule equivalent — e.g. in + `FacetFrame.decompose` and `_piola_facet_rows`. Numeric `numpy.linalg` calls are only valid + when every entry is a plain number: reference-cell-only quantities + (`FacetFrame.reference_coefficients`) or purely numeric direction-basis inversions + (`_scalar_point_rows`, `_piola_point_rows`). Check which regime an array is in before + reaching for a numpy linear-algebra routine. +* **Build sparse GEM-valued arrays with `numpy.full(shape, gem.Zero(), dtype=object)`**, never + `numpy.zeros(shape)` — plain `0` is not interchangeable with `gem.Zero()` when the array will + later be combined with GEM nodes via `+`. +* **"Start from identity, mutate only the rows that need work" is not an optimization, it is + the mathematical content.** `V = identity(ndof)` (`finat/physically_mapped.py`) encodes + "these dofs are push-forward invariant" directly; the invariant-dof detection + (`_invariant_dofs`) simply chooses *not* to touch those rows, rather than writing `1`s + explicitly. Treat the untouched identity rows as the base case of the assembly recursion. +* **Row/column convention, and where the one transpose happens.** Throughout assembly, row + index = reference node, column index = physical node — i.e. the code builds $V$, never $M$ + directly. `ListTensor(V.T)` at the very end is the single place Kirby (2017) Theorem 3.1's + $M = V^T$ gets applied. If something looks transposed, check this convention before + suspecting a sign error. + +### Design strategies that generalize + +* **Design by duality, never by direct construction.** Do not try to write physical basis + functions directly (that requires inverting a Vandermonde system). Write the physical *node* + (a linear functional — point evaluation or moment, always computable), push it forward, and + solve for its expansion in the reference nodes. This is the master strategy behind the whole + framework and generalizes to any element whose nodes, not whose basis functions, are the + simple objects. +* **Recover mathematical type from data, not from a class hierarchy.** `from_fiat` never asks + "is this an `IntegralMomentOfNormalDerivative`?"; it reads `pt_dict`/`deriv_dict` and derives + (order, direction, rank) numerically. This makes the framework forward-compatible with FIAT + functional classes that do not exist yet, as long as they reduce to the same numeric shape — + which essentially all "linear functional built from point/derivative data" dofs do. Prefer + this kind of structural recovery over adding another `isinstance`/type-tag branch whenever a + new functional needs support. +* **Turn a mathematical case-split into a template method, not a conditional.** When a theorem + reads "for pullback type X do A, for pullback type Y do B" against an otherwise identical + algorithmic skeleton, encode the skeleton once (`ZanyPhysicallyMappedElement + .basis_transformation`) and the case-split as small, named hook methods on sibling mixins + (`ScalarPhysicallyMappedElement`, `PiolaPhysicallyMappedElement`). Keep the underlying linear + algebra as free functions taking plain arrays/GEM expressions with no `self` + (`FacetFrame`, `_scalar_facet_rows`, `_piola_facet_rows`, ...): the class layer should only + ever be a thin adapter from "element structure" to "which pure-math function to call," never + a place where new mathematics is derived. This also keeps the generic infrastructure + (`PhysicallyMappedElement` in `finat/physically_mapped.py`, used by hand-coded elements like + Arnold-Winther and HCT) free of any awareness of the theory-specific case-split. +* **Validate one element at a time against an independently computed ground truth, not just + against the closed-form answer you derived.** `test/finat/test_zany_mapping.py + ::check_zany_mapping` tabulates the FIAT element on an actual physical cell and least-squares + fits the physical values against the pulled-back reference tabulation — a computation + completely independent of the symbolic derivation. Its assertion message pretty-prints the + (row, column) location of the relative error; read those indices first, they usually + localize the bug to one entity/dof-type combination before you write any new code. +* **Generalize from one worked example, verify to machine precision, then extend — never + generalize speculatively ahead of a second concrete example.** The actual sequence that + worked: derive the mechanism on Morley alone, check it reproduces the deleted hand-coded + matrix bit-for-bit, *then* extend to Hermite, then Argyris/Bell, then the Piola family. Each + extension needed a genuinely new piece (interior invariance for Piola interior moments, + mixed-order jets for Bell/Argyris vertices, the reciprocal-basis correction for 3D) that a + premature generalization from Morley alone would not have anticipated. + +### Working with the human collaborator + +* **The literature is necessary but not sufficient — treat a stuck derivation as a cue to ask, + not to keep re-deriving.** Kirby (2017) and Aznaran-Kirby-Farrell (2022) work out 2D (or a + simplification that hides a 3D-only effect); none of the papers mention the reciprocal-basis + transformation law 3D Piola elements need. That fix came directly from the user's domain + expertise ("the papers do not consider the reciprocal basis that FIAT implements... Consider + this"), not from re-reading the papers harder. When a derivation matches every 2D case but + fails exactly where a paper's simplifying assumption would hide something, that is the moment + to ask what convention the *implementation* (not the paper) actually uses. +* **Expect, and design for, architectural correction mid-project.** This project's real + trajectory was: prototype on Morley → generalize into a `Functional`/free-function design → + "don't extend further, make the computation generic first, get rid of the very specific + helpers" → extend to Argyris/Bell → extend to the Piola family → "refactor into + `Scalar`/`PiolaPhysicallyMappedElement` mixins so the `if piola` disappears from the loop" → + "move the method body into the Mixin" (keep the generic `PhysicallyMappedElement` free of + zany-specific knowledge). None of these were bug fixes; each was the human reshaping the + design once its true shape became visible. Treat working code as a draft the architecture + will still move under, and re-run the full verification (test suite, `flake8`, `pydocstyle`) + after every such reshaping, not only after functional changes. +* **Keep this file's *why*, not just the *what*.** Git history has the diffs; this file exists + so the next session does not have to rediscover the reciprocal-basis subtlety, the + sign-invariance requirement, or the identity-row idiom by re-reading a diff from scratch. ## Transformation Theory (Kirby 2017; Brubeck & Kirby 2025) @@ -351,7 +483,8 @@ The implementation mirrors the theory factor by factor: * `_normal_tangential_transform(cell, J, detJ, edge)`: returns $(B_{nn}, B_{nt}, Jt)$ for an edge, expressing $B$ entries via $G^{TT}$ Gram data so only GEM-representable quantities appear (`detJ/beta`, `alpha/beta`); 3D variant for faces is - `morley_transform` in `finat/morley.py`. + `morley_transform` in `finat/walkington.py` (Morley itself is automated now; this + 3D helper survives only as Walkington's dependency). * `_edge_transform(V, vorder, eorder, cell, mapping, avg)`: the Jacobi-moment edge rows for integral-variant Argyris/HCT, encoding the endpoint values $P_i^{(1,1)}(\pm 1)$ and the trace-moment coupling. From 4fa258bebc7c1927f3e3f6347bb9ecf789408125 Mon Sep 17 00:00:00 2001 From: Pablo Brubeck Date: Mon, 13 Jul 2026 23:41:06 +0100 Subject: [PATCH 15/34] split AGENTS.md --- AGENTS.md | 343 ++++-------------------------------------------- finat/AGENTS.md | 101 ++++++++++++++ gem/AGENTS.md | 29 ++++ zany_claude.md | 231 ++++++++++++++++++++++++++++++++ 4 files changed, 383 insertions(+), 321 deletions(-) create mode 100644 finat/AGENTS.md create mode 100644 gem/AGENTS.md create mode 100644 zany_claude.md diff --git a/AGENTS.md b/AGENTS.md index 029f3bdd3..f4fb68588 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,13 +15,6 @@ When assisting with contributions to FIAT and the Firedrake project, AI agents a * Any generated code must be executed locally to verify that it functions correctly. * AI tools must not be used to resolve issues that are labeled as 'good first issue'. -### PR expectations - -* CI (`.github/workflows/test.yml`) checks `flake8` and `pydocstyle .` (both - configured in `setup.cfg`); run them locally and fix all findings before pushing. -* Watch for `pydocstyle` D413 (blank line after the last numpydoc section) and D417 - (every argument, including keyword arguments, needs a description) in new docstrings. - --- ## FIAT's Role in the Architecture @@ -67,6 +60,28 @@ Agents modifying FIAT code must follow these fundamental development principles: * Code documentation and comments must explain the present, correct code. * Comments must not detail what a removed or incorrect approach previously did. +## Style and Conventions + +When writing Python code for FIAT, maintain the ecosystem's structural and stylistic integrity: + +* Class attributes must be declared in one visible location. +* Attributes must be initialized in the `__init__` constructor or declared as a `functools.cached_property` if they are expensive to compute. +* Ad hoc lazy initialization discovering attributes via `hasattr`, `setattr`, or `getattr` scattered across methods is strictly prohibited. +* Boolean attributes must be used to record initialization intent and state instead of probing for the presence of state built by an initialization function. +* New code must include type hints on all function and method signatures. +* Public-facing APIs must include properly formatted `numpydoc`-style docstrings. + +## Pull Request Expectations + +* All changes are expected to arrive through GitHub Pull Requests. +* Keep diffs reviewable and focused. +* Before concluding work verify that the relevant subset of the pytest test suite + succeeds locally. +* CI (`.github/workflows/test.yml`) checks `flake8` and `pydocstyle .` (both + configured in `setup.cfg`); run them locally and fix all findings before pushing. +* Watch for `pydocstyle` D413 (blank line after the last numpydoc section) and D417 + (every argument, including keyword arguments, needs a description) in new docstrings. + --- ## Pattern Matching and Mathematical Reasoning @@ -138,36 +153,6 @@ about *how to design and debug this kind of code*, not just about this one proje cell** (see `test/finat/conftest.py::MyMapping`'s deliberately irregular vertex coordinates) — a symmetric test cell can accidentally hide a sign bug that only shows up on a generic mesh. -### Confusing GEM patterns, clarified - -* **Use operator overloading, not manual node construction.** `gem.Node` overloads - `+ - * / ** @` (via `as_gem`/`componentwise`) to work transparently across GEM nodes, - `gem.Literal`, and plain Python/numpy numbers, with automatic `Zero` folding. Write - `havg**(-m)`, never `gem.Power(havg, gem.Literal(-m))`: the operator form also works when - `havg` is a raw float (as in the test harness's `MyMapping.cell_size()`) whereas a manual - `gem.Power` call assumes GEM operands and will not always coerce correctly. -* **Never call `numpy.linalg.inv`/`solve` on a GEM-valued matrix.** LAPACK cannot see inside - GEM expressions. Symbolic linear solves use `adjugate(A) / determinant(A)` - (`finat/physically_mapped.py`) — the symbolic Cramer's-rule equivalent — e.g. in - `FacetFrame.decompose` and `_piola_facet_rows`. Numeric `numpy.linalg` calls are only valid - when every entry is a plain number: reference-cell-only quantities - (`FacetFrame.reference_coefficients`) or purely numeric direction-basis inversions - (`_scalar_point_rows`, `_piola_point_rows`). Check which regime an array is in before - reaching for a numpy linear-algebra routine. -* **Build sparse GEM-valued arrays with `numpy.full(shape, gem.Zero(), dtype=object)`**, never - `numpy.zeros(shape)` — plain `0` is not interchangeable with `gem.Zero()` when the array will - later be combined with GEM nodes via `+`. -* **"Start from identity, mutate only the rows that need work" is not an optimization, it is - the mathematical content.** `V = identity(ndof)` (`finat/physically_mapped.py`) encodes - "these dofs are push-forward invariant" directly; the invariant-dof detection - (`_invariant_dofs`) simply chooses *not* to touch those rows, rather than writing `1`s - explicitly. Treat the untouched identity rows as the base case of the assembly recursion. -* **Row/column convention, and where the one transpose happens.** Throughout assembly, row - index = reference node, column index = physical node — i.e. the code builds $V$, never $M$ - directly. `ListTensor(V.T)` at the very end is the single place Kirby (2017) Theorem 3.1's - $M = V^T$ gets applied. If something looks transposed, check this convention before - suspecting a sign error. - ### Design strategies that generalize * **Design by duality, never by direct construction.** Do not try to write physical basis @@ -232,287 +217,3 @@ about *how to design and debug this kind of code*, not just about this one proje * **Keep this file's *why*, not just the *what*.** Git history has the diffs; this file exists so the next session does not have to rediscover the reciprocal-basis subtlety, the sign-invariance requirement, or the identity-row idiom by re-reading a diff from scratch. - -## Transformation Theory (Kirby 2017; Brubeck & Kirby 2025) - -The mathematical framework for mapping non-affinely-equivalent elements. Notation: the -geometric map goes **from physical to reference**, $F: K \to \hat{K}$; pullback -$F^*(\hat{f}) = \hat{f}\circ F$; push-forward of a node $F_*(n) = n \circ F^*$. - -* Goal: the matrix $M$ with $\Psi = M\, F^*(\hat\Psi)$, expressing physical nodal basis - functions as combinations of pulled-back reference basis functions. -* Duality is the whole game: with $B_{ij} = F_*(n_i)(\hat\psi_j) = n_i(F^*(\hat\psi_j))$, - the Kronecker property gives $M = B^{-T}$ and $V = B^{-1}$, where $V$ relates nodes: - $\hat{N} = V\, F_*(N)$ (restricted to $P$). Hence **$M = V^T$**, and $V$ is what one - actually constructs, because push-forwards of nodes are computable by the chain rule. -* Affine equivalence (Lagrange): $F_*(N) = \hat{N}$, so $M = I$. -* Affine-interpolation equivalence (Hermite): spans of nodes at each point are preserved, - so $V$ is block diagonal, one small block per point-node group (e.g. $J^{-T}$ blocks for - vertex gradients — in the *paper's* $J$; see the FInAT convention below). -* Neither (Morley, Argyris, HCT): edge normal derivatives alone do not push forward into - the span of reference nodes. Fix by a **compatible nodal completion** $N^c \supset N$, - $\hat{N}^c \supset \hat{N}$ with $\mathrm{span}(F_*(N^c)) = \mathrm{span}(\hat{N}^c)$ - (add the tangential-derivative partners so each edge carries a full gradient). Then - $$V = E\, V^c\, D,$$ - with the three factors having distinct mathematical roles: - * $D \in \mathbb{R}^{\mu\times\nu}$: expresses the completed physical nodes in terms of - the actual physical nodes, *restricted to* $P$ ($\pi N^c = D\, \pi N$). Rows for nodes - already in $N$ are Boolean. Rows for completion nodes come from a univariate exactness - argument on the edge: a tangential-derivative quantity of a polynomial of known edge - degree is an exact linear combination of the endpoint values/derivatives (FTC for HCT's - $\mu^t_e$; the quintic endpoint rule for Argyris/Bell midpoint tangential derivatives). - This is the only factor that uses the polynomial degree of the space. - * $V^c \in \mathbb{R}^{\mu\times\mu}$: block diagonal, pure chain rule, - $\hat{N}^c = V^c F_*(N^c)$. Vertex value $\to 1$; vertex gradient $\to$ Jacobian block; - vertex Hessian $\to$ symmetric-square block $\Theta$; edge normal/tangential pair $\to$ - the $2\times2$ block $B_i = \hat{G}_i J^{-T} G_i^T$ (paper's $J$), where - $G = [\mathbf{n}\; \mathbf{t}]^T$. Only the first row of $B_i$ is needed - ($B_{nn}$, $B_{nt}$) since tangential rows are discarded by $E$. - * $E \in \mathbb{R}^{\nu\times\mu}$: Boolean extraction of $\hat{N}$ from $\hat{N}^c$. -* Constrained spaces, $F^*(\hat{P}) \neq P$ (Bell, reduced HCT): the space is - $P = \bigcap_i \mathrm{null}(\lambda_i)$ inside a larger $\tilde{P}$ that *is* preserved - (e.g. reduced HCT = HCT functions whose edge normal derivative is linear; - $\lambda_i$ = moment of the normal derivative against the quadratic Legendre polynomial - on edge $i$). Build the **extended element** $(K, \tilde{P}, [N; L])$ with the - constraints $\lambda_i$ appended as extra nodes; it is a valid finite element, and its - first $\nu$ nodal basis functions are exactly the nodal basis of the constrained - element. Transform the extended element with the $E V^c D$ machinery (completing each - $\lambda_i$ with its tangential partner $\lambda_i'$, again eliminated through an edge - exactness rule), then discard the constraint rows. -* Brubeck & Kirby 2025 refinements: - * Define *reference* edge nodes as integral **averages** ($1/|\hat{e}|$ scaling) while - physical nodes are plain moments: the edge-length Jacobian of the line integral is then - absorbed and no reference-edge-length bookkeeping or orientation logic is needed. - * High-order HCT/Argyris: edge normal-derivative moments are taken against Jacobi - polynomials $P_i^{(1,1)}$ (weights $(2,2)$ for Argyris, matching the vertex jet order), - so the edge-based functions are hierarchical. The tangential completion - $\mu^t_{e,i}$ integrates by parts against the *trace* moments (defined against - $\frac{d}{ds}P_i^{(1,1)}$): $\mu^t_{e,i}(f) = -\mu_{e,i}(f) + P_i^{(1,1)}(1)\, - \delta_{v_b}(f) - P_i^{(1,1)}(-1)\,\delta_{v_a}(f)$. This couples normal moments to - trace moments, not just vertex dofs. - -### Active work: automating the transformation (see `plan_zany_auto.md`) - -Goal: replace the hand-coded `basis_transformation` methods with a helper that derives -$V = E V^c D$ directly from a FIAT element's dual basis. The implementation must mirror -the theory factor by factor, not merely reproduce matrix entries: - -**Status (2026-07-13).** The symbolic dof lives in `finat/functional.py` as -`finat.PhysicallyMappedFunctional`. `finat/physically_mapped.py` stays fully generic: -`PhysicallyMappedElement` is unchanged from before this project, still an abstract -mixin with no knowledge of the zany theory, used as-is by hand-coded elements (AW, -HCT, PowellSabin, Walkington, ...). All the automation lives in `finat/zany.py`, as a -template method on `ZanyPhysicallyMappedElement(PhysicallyMappedElement)`: the -entity-by-entity assembly loop is implemented once, in its concrete -`basis_transformation`, calling four hooks (`_check_mapping`, `_invariant_dofs`, -`_facet_dof_rows`, `_point_dof_rows`) that carry ALL mapping-specific knowledge — the -loop itself contains no `if piola` anywhere. Two mixins implement those hooks, -`ScalarPhysicallyMappedElement` (affine pullback: Morley, Hermite, Argyris, Bell) and -`PiolaPhysicallyMappedElement` ((double) contravariant Piola: MTW, Johnson-Mercier, -Guzman-Neilan), plus the pure math functions they call (`FacetFrame`, -`_scalar_facet_rows`, `_scalar_point_rows`, `_piola_facet_rows`, `_piola_point_rows`) — -these take plain arrays/GEM expressions, no `self`, so the mathematics stays readable -independent of the class plumbing. Concrete elements (`finat.Morley`, etc.) are now -just a citation plus a FIAT constructor call: mixing in the right base class is enough, -`basis_transformation` is inherited (MRO example: `Morley -> ScalarPhysicallyMappedElement --> ZanyPhysicallyMappedElement -> PhysicallyMappedElement -> ...`). `ndof` truncation is -no longer a parameter; the loop always slices by `self.space_dimension()`, which -constrained elements (Bell, GN) already override. Tests: -`test/finat/test_zany_automation.py`; `check_zany_mapping` lives in the finat conftest -and is provided to test modules as a pytest fixture (pytest runs with -`--import-mode=importlib`, so test modules cannot import from each other or from -conftest). - -**Framework design.** A dof is a symbolic `finat.PhysicallyMappedFunctional`: -$\ell(f) = \sum_q w_q \langle D, \nabla^m f(x_q)\rangle$ with numeric points/weights -and a direction tensor $D$ that is numeric on the reference cell and GEM otherwise. -There is *no dispatch over FIAT functional types*: `PhysicallyMappedFunctional.from_fiat` -reads only `pt_dict`/`deriv_dict` and recovers the order and common direction numerically -(rank-one SVD of the derivative weights). Operations: covariant `pullback(J)` -(contract direction slots with $J$), `with_direction`, and numeric `evaluate` against -a nodal basis (the generalized Vandermonde realizing the $D$ factor of $V = E V^c D$). -The row of $V$ for a reference node $\hat\ell$ with direction $\hat d = a\hat n + -\sum_k \beta_k \hat t_k$ (numeric split) is assembled generically: - -* $a = 0$ (value dofs, or tangential directions): push-forward invariant, identity row. -* otherwise solve $J\hat d = x_0 C + \sum_k x_k J\hat t_k$ symbolically - (`FacetFrame.decompose`, adjugate/determinant of the polynomial frame matrix), where - $C$ = generalized cross product of the mapped tangents. The physical node has - direction $aN + \sum\beta_k J\hat t_k$ with $N = \kappa C/\|C\|$, so its coefficient - is $c = x_0\|C\|/(\kappa a)$ and the tangential remainders are $r_k = x_k - c\beta_k$ - (careful: *not* $a x_k$ — the SVD direction may be $-\hat n$, and all formulas must be - invariant under that sign flip; a sign bug here flips exactly the edges where the SVD - chose the opposite orientation). -* the remainders multiply completion functionals along *mapped reference tangents*, - which coincide with reference functionals; `PhysicallyMappedFunctional.evaluate` gives their numeric - expansion in the element's own nodes, and the row combination recurses through the - already-assembled rows of $V$ (entities processed in increasing dimension), which - will later let completions couple to vertex jets (Argyris/HCT) for free. - -Derivative nodes *away from facets* (`_scalar_point_rows`, covering Hermite vertex -gradients) have no geometric frame: FIAT keeps Cartesian directions on the physical -cell, so the group of derivative nodes on the entity acts as its own completion — this -is precisely affine-interpolation equivalence. The pulled-back direction $J\hat d_i$ -is expanded in the group's own (numeric) direction basis, with weight-ratio factors -making the expansion invariant under the SVD scale/sign ambiguity of each node's -recovered $(w, D)$ factorization. The group must span the derivative jet, share its -points, and have pairwise-parallel weights; otherwise `NotImplementedError`. - -Key facts the framework rests on: - -* **FIAT normals are "UFC consistent":** computed from the tangents by the same formula - on reference and physical cells (`UFCTriangle.compute_normal` rotates the scaled edge - tangent; `UFCTetrahedron.compute_normal` is $-2\times$ the unit cross product of the - scaled face tangents), with cell-independent magnitude. Hence $N = \kappa C/\|C\|$ - with $\kappa$ recoverable from reference data, and no orientation logic is needed - (assumes $\det J > 0$). For the record, the fully simplified closed forms the solve - reproduces are $a = \det J\sqrt{\det\hat G/\det G}$ and $b = G^{-1}T^TJ\hat n$ with - Gram matrices of the (mapped) tangents. -* **Integral averages are push-forward invariant.** FIAT's Morley dofs are averages - (`FacetQuadratureRule(..., avg=True)`), so physical nodes share the reference weights - and no `physical_edge_lengths` appear. The framework *assumes* measure-intrinsic - moments (the Brubeck & Kirby 2025 reference-node convention) — documented in - `finat/functional.py`. -* **The conditioning $h$-scaling generalizes via `max_deriv_order`.** Column $j$ is - scaled by $h_E^{-m}$ where $m$ is the derivative order of node $j$ and $h_E$ averages - `cell_size()` over the vertices of its entity; reproduces every per-element - hand-written scaling. `cell_size()` returns raw numpy values in the test mappings but - GEM in Firedrake, so use operator arithmetic (`havg**(-m)`), not GEM constructors - (GEM overloads `+ - * / ** @` with `Zero`/constant folding; keep numpy object arrays - on the left when scaling by a GEM scalar). - -Extensions beyond first order and Morley/Hermite: - -* `PhysicallyMappedFunctional` directions live in derivative multi-index space (`multiindices`, axis - order for $m=1$); `pullback` distributes them over a symmetric tensor (dividing by - multiplicities), contracts every slot with $J$ (`numpy.tensordot` on object arrays), - and collapses back. Point-jet groups are split per order; each order solves in its - own multi-index direction basis (Argyris/Bell vertex jets: gradient + Hessian). -* Facet completions of Argyris edge moments couple to vertex jets and same-edge trace - moments; the existing row recursion handles both with no new code (trace moments are - order-0 and thus invariant; FIAT builds all these moments with - `FacetQuadratureRule(avg=True)`, i.e. measure-intrinsic, as the framework assumes). -* `ScalarPhysicallyMappedElement.avg = False` (an instance attribute Argyris sets from - its constructor kwarg) reproduces the legacy FInAT convention where physical facet - moments are plain integrals: their columns are divided by the physical facet measure - $\|C\| |\hat e| / \|\hat C\|$ (`FacetFrame.measure`). Single-point facet dofs - (Argyris "point" variant) are unaffected. -* Bell is the extended-element pattern: FIAT.Bell is the 21-node quintic element with - the constraint functionals as extra edge nodes; overriding `space_dimension()` to 18 - drops the constraint *columns* of $V$ at the end of the template method (their rows - still contribute the $D$-matrix entries through the completion recursion), and the - FInAT element overrides `entity_dofs`. -* Known convention change: the generic $h^{-m}$ conditioning scaling now also applies - to integral-variant Argyris edge moments, which the hand-written code left unscaled - (Morley scaled them; the legacy convention was inconsistent). Invisible when - `cell_size == 1`; flag in PR review. - -**Piola-mapped elements** (Aznaran, Kirby & Farrell 2022). `PhysicallyMappedFunctional` carries a -value rank: component weight profiles (nq x sd^rank) parsed from `pt_dict` component -tuples. Under contravariant Piola the roles of the scalar case are mirrored: the -*scaled* facet normal is the cofactor image $K\hat n_s$, $K = \mathrm{adj}(J)^T$ -(exactly the physical `compute_scaled_normal`, cross product of mapped tangents), so -pure normal moments are invariant, while scaled tangents map by $J$. `_piola_facet_rows` -works with per-point *frame-coordinate profiles* (handles 3D MTW's point-varying -RT-mapped tangential directions): the pulled-back profile is contracted per value slot -with the mixing matrix $Y$; tangential profiles are matched within the facet group by a -numeric pseudo-inverse; the residual normal profile is eliminated by per-point normal -moments through the Vandermonde recursion (this is where e.g. tangential-to-normal -couplings emerge). Key subtlety (in any dimension > 2): FIAT builds tangential value -components on the **reciprocal basis** (`cross(n, t_k)`), which transforms in-plane -contravariantly: absorb $S^{-1} = (\det\hat G_t/\det G_t)\hat G_t^{-1} G_t$ -(tangent Gram change) into $Y$'s tangential rows; in 2D $S = 1$, which hides the effect. -Interior value moments are Piola-invariant by construction (scaled-normal components for -JM; covariant Nedelec test fields for MTW order 2 cancel the contravariant trial -exactly). Double contravariant (tensor) elements use the same code: one contraction per -value slot. MTW (2D/3D) and Johnson-Mercier (2D/3D) are reimplemented this way, matching -the deleted hand code to machine precision; RaviartThomas-type elements come out as pure -identity (Piola-equivalent) automatically. - -GuzmanNeilanFirstKindH1 (orders 0-2, 2D/3D) is also automatic: vertex/edge point -values are value point-groups mapping by $K$ (`_piola_point_rows`, the mirror of -`_scalar_point_rows`), and the trailing tangential facet constraints are dropped since -`PiolaBubbleElement.space_dimension()` already returns the reduced count (the Bell -pattern, inherited rather than passed as a parameter); `PiolaBubbleElement.__init__` -still provides the reduced `entity_dofs` bookkeeping. `GuzmanNeilanFirstKindH1` is -`class GuzmanNeilanFirstKindH1(PiolaPhysicallyMappedElement, PiolaBubbleElement)`: MRO -puts the automatic `basis_transformation` first, while `PiolaBubbleElement.__init__` -(reached via `super()`) still sets up `self._element` and the reduced dof bookkeeping. Its hand-derived vertex-facet coupling correction ("fix -discrepancy" in `finat/piola_mapped.py`) emerges automatically from the Vandermonde -residual elimination, since the per-point normal moments evaluate against vertex basis -functions of the extended element. - -Next steps: remaining PiolaBubbleElement users (GN second kind, H1div: interior -derivative moments need the divergence detJ rule), ArnoldWinther (vertex tensor values -+ higher facet moments), the extended-element path for reduced HCT (macro polynomial -spaces); covariant elements. - -The implementation mirrors the theory factor by factor: - -1. **Sparsity from topology**: each row of $V$ is a reference node; its nonzero columns - are the physical nodes on the same entity (via $V^c$) plus the nodes on adjacent - entities pulled in by $D$ (e.g. edge rows couple to the edge's vertex dofs, and for - hierarchical elements to the same edge's trace moments). Derive this from - `entity_dofs()` and functional types (`FIAT/functional.py`), never hard-code indices. -2. **Completion from functional type**: a normal-derivative node's completion partner is - the corresponding tangential-derivative node; the completion is a per-entity statement, - determined by which components of the derivative jet the dual basis lacks. -3. **$D$ from unisolvence, not from closed-form rules**: the univariate exactness rules - (FTC, quintic endpoint rule, integration by parts against trace moments) are all - instances of one computation — express a completion functional applied to the - polynomial space in the basis of the element's own nodes, i.e. solve with the - generalized Vandermonde matrix. This is where GEM-based dual evaluation comes in. -4. Constrained spaces (reduced HCT, Bell) reuse the same machinery on the extended - element; the FIAT element must expose the constraint functionals as extra dofs. - -### FInAT implementation conventions - -* `coordinate_mapping.jacobian_at(point)` returns $\partial x_{\text{phys}} / \partial - x_{\text{ref}}$ — the **inverse** of the papers' $J$ (papers map physical → reference). - So where the paper writes $J^{-T}$, the code uses the FInAT Jacobian transposed; e.g. - Hermite's vertex blocks are `J[j, k]` entries of $M$ directly. -* `basis_transformation` builds the numpy object array `V` (row = reference node, column - = physical node, entries are GEM scalars) and returns `gem.ListTensor(V.T)` = $M$. - Start from `identity(ndof)` (in `finat/physically_mapped.py`) and overwrite the - non-identity rows. -* Existing generalized helpers, all in `finat/argyris.py`: - * `_jet_transform(J, order)`: chain-rule block for a symmetric derivative jet of any - order (order 1 → Jacobian, order 2 → $\Theta$, …), handling symmetric-component - flattening. - * `_vertex_transform(V, vorder, cell, mapping)`: places jet blocks for every vertex. - * `_normal_tangential_transform(cell, J, detJ, edge)`: returns $(B_{nn}, B_{nt}, Jt)$ - for an edge, expressing $B$ entries via $G^{TT}$ Gram data so only GEM-representable - quantities appear (`detJ/beta`, `alpha/beta`); 3D variant for faces is - `morley_transform` in `finat/walkington.py` (Morley itself is automated now; this - 3D helper survives only as Walkington's dependency). - * `_edge_transform(V, vorder, eorder, cell, mapping, avg)`: the Jacobi-moment edge rows - for integral-variant Argyris/HCT, encoding the endpoint values - $P_i^{(1,1)}(\pm 1)$ and the trace-moment coupling. -* Reduced/constrained elements (`ReducedHsiehCloughTocher` in `finat/hct.py`, `Bell`): - the FIAT element is the *extended* element (12 basis functions for reduced HCT), the - FInAT element exposes only $\nu$ dofs: `V = identity(numbf, ndof)` is rectangular, - `entity_dofs()` is overridden to empty the constrained entities, and - `space_dimension()` returns the reduced count. -* Conditioning convention: after assembling `V`, columns associated with derivative dofs - are rescaled by powers of `coordinate_mapping.cell_size()` (a per-vertex $h$). This - redefines the physical dofs as $h$-scaled derivatives — consistent across cells because - the scaling depends only on shared vertices/edges. Any new transformation must follow - the same convention or mass-matrix conditioning degrades - (`test/finat/test_mass_conditioning.py`). -* Verification: `test/finat/test_zany_mapping.py::check_zany_mapping` computes the exact - $M$ numerically by tabulating the FIAT element on a *physical* cell and least-squares - solving against pulled-back (and Piola-mapped, if applicable) reference tabulations, - then compares against `basis_transformation` evaluated through `gem.interpreter`. Its - assertion message pretty-prints the relative-error matrix with row/column indices — - read those indices to identify which dof couplings are wrong. - -## Style and Conventions - -When writing Python code for FIAT, maintain the ecosystem's structural and stylistic integrity: - -* Class attributes must be declared in one visible location. -* Attributes must be initialized in the `__init__` constructor or declared as a `functools.cached_property` if they are expensive to compute. -* Ad hoc lazy initialization discovering attributes via `hasattr`, `setattr`, or `getattr` scattered across methods is strictly prohibited. -* Boolean attributes must be used to record initialization intent and state instead of probing for the presence of state built by an initialization function. -* New code must include type hints on all function and method signatures. -* Public-facing APIs must include properly formatted `numpydoc`-style docstrings. diff --git a/finat/AGENTS.md b/finat/AGENTS.md new file mode 100644 index 000000000..9ed7be9d6 --- /dev/null +++ b/finat/AGENTS.md @@ -0,0 +1,101 @@ +## FInAT implementation conventions + +* `coordinate_mapping.jacobian_at(point)` returns $\partial x_{\text{phys}} / \partial + x_{\text{ref}}$ — the **inverse** of the papers' $J$ (papers map physical → reference). + So where the paper writes $J^{-T}$, the code uses the FInAT Jacobian transposed; e.g. + Hermite's vertex blocks are `J[j, k]` entries of $M$ directly. +* `basis_transformation` builds the numpy object array `V` (row = reference node, column + = physical node, entries are GEM scalars) and returns `gem.ListTensor(V.T)` = $M$. + Start from `identity(ndof)` (in `finat/physically_mapped.py`) and overwrite the + non-identity rows. +* Existing generalized helpers, all in `finat/argyris.py`: + * `_jet_transform(J, order)`: chain-rule block for a symmetric derivative jet of any + order (order 1 → Jacobian, order 2 → $\Theta$, …), handling symmetric-component + flattening. + * `_vertex_transform(V, vorder, cell, mapping)`: places jet blocks for every vertex. + * `_normal_tangential_transform(cell, J, detJ, edge)`: returns $(B_{nn}, B_{nt}, Jt)$ + for an edge, expressing $B$ entries via $G^{TT}$ Gram data so only GEM-representable + quantities appear (`detJ/beta`, `alpha/beta`); 3D variant for faces is + `morley_transform` in `finat/walkington.py` (Morley itself is automated now; this + 3D helper survives only as Walkington's dependency). + * `_edge_transform(V, vorder, eorder, cell, mapping, avg)`: the Jacobi-moment edge rows + for integral-variant Argyris/HCT, encoding the endpoint values + $P_i^{(1,1)}(\pm 1)$ and the trace-moment coupling. +* Reduced/constrained elements (`ReducedHsiehCloughTocher` in `finat/hct.py`, `Bell`): + the FIAT element is the *extended* element (12 basis functions for reduced HCT), the + FInAT element exposes only $\nu$ dofs: `V = identity(numbf, ndof)` is rectangular, + `entity_dofs()` is overridden to empty the constrained entities, and + `space_dimension()` returns the reduced count. +* Conditioning convention: after assembling `V`, columns associated with derivative dofs + are rescaled by powers of `coordinate_mapping.cell_size()` (a per-vertex $h$). This + redefines the physical dofs as $h$-scaled derivatives — consistent across cells because + the scaling depends only on shared vertices/edges. Any new transformation must follow + the same convention or mass-matrix conditioning degrades + (`test/finat/test_mass_conditioning.py`). +* Verification: `test/finat/test_zany_mapping.py::check_zany_mapping` computes the exact + $M$ numerically by tabulating the FIAT element on a *physical* cell and least-squares + solving against pulled-back (and Piola-mapped, if applicable) reference tabulations, + then compares against `basis_transformation` evaluated through `gem.interpreter`. Its + assertion message pretty-prints the relative-error matrix with row/column indices — + read those indices to identify which dof couplings are wrong. + +## Mathematical structures to recognize in FIAT/FInAT + +* **A degree of freedom is fully described by five numbers, not by its FIAT class.** Every + functional FIAT builds from `pt_dict`/`deriv_dict` reduces to (points, weights, derivative + order $m$, a direction tensor of rank $m$, a value rank for vector/tensor-valued dofs). + `IntegralMomentOfNormalDerivative`, `PointNormalDerivative`, `TensorBidirectionalIntegralMoment`, + etc. are just different ways of *constructing* the same five numbers. Recognizing this + collapses "N functional types to support" into "one shape to recover numerically" + (`finat.PhysicallyMappedFunctional.from_fiat`, `finat/functional.py`). +* **Pullback is always "contract each tensor slot of the direction with a fixed matrix."** + Order-0 (values) are invariant (zero slots to contract). Order-$m$ derivatives contract $m$ + slots with the Jacobian $J$ (the chain rule). Rank-$r$ Piola values contract $r$ slots with + the cofactor matrix $K = \operatorname{adj}(J)^T$. This is *the same operation* with a + different matrix, which is why the scalar and Piola code in `finat/zany.py` are mirror images + of each other (`_scalar_point_rows` / `_piola_point_rows`, `_scalar_facet_rows` / + `_piola_facet_rows`) rather than unrelated implementations. +* **Frame decomposition is the one computational primitive underlying every non-affine + element.** Facet dofs (Morley/Argyris/Bell normal derivatives; MTW/JM/GN normal-tangential + moments) and vertex-jet completions (Hermite/Argyris/Bell gradients and Hessians) are all + solved the *same* way: split a direction/profile into an invariant part and a part that needs + completing, express the pulled-back quantity in the frame built from the mapped generators of + that split (`FacetFrame`, or the direction-basis inverse in `_scalar_point_rows`), and solve + symbolically via `adjugate`/`determinant`. Once this pattern is visible, "add a new element" + stops being "derive new math" and becomes "which invariant subspace, which frame." +* **Constrained/extended elements are the same construction as a restriction.** Bell and + Guzman-Neilan are both "take the extended FIAT element with its constraint functionals as + extra dofs, transform the whole thing, then keep only the first $\nu$ columns." This is + Kirby (2017) §5's extended-element proposition, and in code it is nothing more than + `space_dimension()` returning a smaller count than the FIAT element's — no special-casing + needed in the transformation loop itself. + +### Confusing mathematical ideas, clarified + +* **The Jacobian direction is flipped between the papers and the code.** The papers define + $F$ from physical to reference space, so their $J$ is FInAT's Jacobian *inverse*. + `coordinate_mapping.jacobian_at(point)` returns $\partial x_{\text{phys}}/\partial + x_{\text{ref}}$ (see "FInAT implementation conventions" below); a paper's $J^{-T}$ is FInAT's + plain Jacobian, transposed. This is a constant source of "why does my formula look + transposed" confusion — check this convention flip before suspecting a sign or index bug. +* **Components against a reciprocal/dual basis transform contragrediently — this is the single + subtlety that broke 3D and is not in any of the papers.** FIAT builds the tangential + component of 3D Piola-mapped dofs (MTW) using `cross(n, t_k)`, the *reciprocal* partner of + the tangent frame, not the tangent frame itself. A general fact from differential geometry: + if a frame transforms by a matrix $A$, components expressed against its *reciprocal* frame + transform by $A^{-T}$ (up to a determinant factor), not by $A$. Kirby (2017)/Aznaran-Kirby- + Farrell (2022) only work out the 2D case, where the tangent "plane" is 1-dimensional and this + correction $S^{-1}$ collapses to $1$ — silently hiding the effect. **Whenever a FIAT dual set + builds a direction via a cross product against the normal (a common way to get "the other + in-plane directions" in 3D), check whether its transformation law is contragredient before + assuming it matches the direct frame.** +* **Numerically recovered invariants (SVD, pinv) are only defined up to a group action, and + formulas built from them must be invariant under that action.** `from_fiat`'s SVD recovers a + direction/weight pair $(\hat n, w)$ up to a joint sign flip ($\hat n \to -\hat n$, $w \to + -w$ leaves the functional unchanged). A formula like $r_k = a x_k + (1-c)\beta_k$ that + implicitly assumes a particular sign will be wrong on exactly the subset of entities where + SVD happened to pick the other sign — a bug that looks like it "mostly works" and is + otherwise very hard to localize. The fix, $r_k = x_k - c\beta_k$, is invariant under the + joint flip. **Always test the full topology of a non-degenerate, non-symmetric physical + cell** (see `test/finat/conftest.py::MyMapping`'s deliberately irregular vertex coordinates) — + a symmetric test cell can accidentally hide a sign bug that only shows up on a generic mesh. diff --git a/gem/AGENTS.md b/gem/AGENTS.md new file mode 100644 index 000000000..7c52b4374 --- /dev/null +++ b/gem/AGENTS.md @@ -0,0 +1,29 @@ +### Confusing GEM patterns, clarified + +* **Use operator overloading, not manual node construction.** `gem.Node` overloads + `+ - * / ** @` (via `as_gem`/`componentwise`) to work transparently across GEM nodes, + `gem.Literal`, and plain Python/numpy numbers, with automatic `Zero` folding. Write + `havg**(-m)`, never `gem.Power(havg, gem.Literal(-m))`: the operator form also works when + `havg` is a raw float (as in the test harness's `MyMapping.cell_size()`) whereas a manual + `gem.Power` call assumes GEM operands and will not always coerce correctly. +* **Never call `numpy.linalg.inv`/`solve` on a GEM-valued matrix.** LAPACK cannot see inside + GEM expressions. Symbolic linear solves use `adjugate(A) / determinant(A)` + (`finat/physically_mapped.py`) — the symbolic Cramer's-rule equivalent — e.g. in + `FacetFrame.decompose` and `_piola_facet_rows`. Numeric `numpy.linalg` calls are only valid + when every entry is a plain number: reference-cell-only quantities + (`FacetFrame.reference_coefficients`) or purely numeric direction-basis inversions + (`_scalar_point_rows`, `_piola_point_rows`). Check which regime an array is in before + reaching for a numpy linear-algebra routine. +* **Build sparse GEM-valued arrays with `numpy.full(shape, gem.Zero(), dtype=object)`**, never + `numpy.zeros(shape)` — plain `0` is not interchangeable with `gem.Zero()` when the array will + later be combined with GEM nodes via `+`. +* **"Start from identity, mutate only the rows that need work" is not an optimization, it is + the mathematical content.** `V = identity(ndof)` (`finat/physically_mapped.py`) encodes + "these dofs are push-forward invariant" directly; the invariant-dof detection + (`_invariant_dofs`) simply chooses *not* to touch those rows, rather than writing `1`s + explicitly. Treat the untouched identity rows as the base case of the assembly recursion. +* **Row/column convention, and where the one transpose happens.** Throughout assembly, row + index = reference node, column index = physical node — i.e. the code builds $V$, never $M$ + directly. `ListTensor(V.T)` at the very end is the single place Kirby (2017) Theorem 3.1's + $M = V^T$ gets applied. If something looks transposed, check this convention before + suspecting a sign error. diff --git a/zany_claude.md b/zany_claude.md new file mode 100644 index 000000000..ec1dc4617 --- /dev/null +++ b/zany_claude.md @@ -0,0 +1,231 @@ +## Transformation Theory (Kirby 2017; Brubeck & Kirby 2025) + +The mathematical framework for mapping non-affinely-equivalent elements. Notation: the +geometric map goes **from physical to reference**, $F: K \to \hat{K}$; pullback +$F^*(\hat{f}) = \hat{f}\circ F$; push-forward of a node $F_*(n) = n \circ F^*$. + +* Goal: the matrix $M$ with $\Psi = M\, F^*(\hat\Psi)$, expressing physical nodal basis + functions as combinations of pulled-back reference basis functions. +* Duality is the whole game: with $B_{ij} = F_*(n_i)(\hat\psi_j) = n_i(F^*(\hat\psi_j))$, + the Kronecker property gives $M = B^{-T}$ and $V = B^{-1}$, where $V$ relates nodes: + $\hat{N} = V\, F_*(N)$ (restricted to $P$). Hence **$M = V^T$**, and $V$ is what one + actually constructs, because push-forwards of nodes are computable by the chain rule. +* Affine equivalence (Lagrange): $F_*(N) = \hat{N}$, so $M = I$. +* Affine-interpolation equivalence (Hermite): spans of nodes at each point are preserved, + so $V$ is block diagonal, one small block per point-node group (e.g. $J^{-T}$ blocks for + vertex gradients — in the *paper's* $J$; see the FInAT convention below). +* Neither (Morley, Argyris, HCT): edge normal derivatives alone do not push forward into + the span of reference nodes. Fix by a **compatible nodal completion** $N^c \supset N$, + $\hat{N}^c \supset \hat{N}$ with $\mathrm{span}(F_*(N^c)) = \mathrm{span}(\hat{N}^c)$ + (add the tangential-derivative partners so each edge carries a full gradient). Then + $$V = E\, V^c\, D,$$ + with the three factors having distinct mathematical roles: + * $D \in \mathbb{R}^{\mu\times\nu}$: expresses the completed physical nodes in terms of + the actual physical nodes, *restricted to* $P$ ($\pi N^c = D\, \pi N$). Rows for nodes + already in $N$ are Boolean. Rows for completion nodes come from a univariate exactness + argument on the edge: a tangential-derivative quantity of a polynomial of known edge + degree is an exact linear combination of the endpoint values/derivatives (FTC for HCT's + $\mu^t_e$; the quintic endpoint rule for Argyris/Bell midpoint tangential derivatives). + This is the only factor that uses the polynomial degree of the space. + * $V^c \in \mathbb{R}^{\mu\times\mu}$: block diagonal, pure chain rule, + $\hat{N}^c = V^c F_*(N^c)$. Vertex value $\to 1$; vertex gradient $\to$ Jacobian block; + vertex Hessian $\to$ symmetric-square block $\Theta$; edge normal/tangential pair $\to$ + the $2\times2$ block $B_i = \hat{G}_i J^{-T} G_i^T$ (paper's $J$), where + $G = [\mathbf{n}\; \mathbf{t}]^T$. Only the first row of $B_i$ is needed + ($B_{nn}$, $B_{nt}$) since tangential rows are discarded by $E$. + * $E \in \mathbb{R}^{\nu\times\mu}$: Boolean extraction of $\hat{N}$ from $\hat{N}^c$. +* Constrained spaces, $F^*(\hat{P}) \neq P$ (Bell, reduced HCT): the space is + $P = \bigcap_i \mathrm{null}(\lambda_i)$ inside a larger $\tilde{P}$ that *is* preserved + (e.g. reduced HCT = HCT functions whose edge normal derivative is linear; + $\lambda_i$ = moment of the normal derivative against the quadratic Legendre polynomial + on edge $i$). Build the **extended element** $(K, \tilde{P}, [N; L])$ with the + constraints $\lambda_i$ appended as extra nodes; it is a valid finite element, and its + first $\nu$ nodal basis functions are exactly the nodal basis of the constrained + element. Transform the extended element with the $E V^c D$ machinery (completing each + $\lambda_i$ with its tangential partner $\lambda_i'$, again eliminated through an edge + exactness rule), then discard the constraint rows. +* Brubeck & Kirby 2025 refinements: + * Define *reference* edge nodes as integral **averages** ($1/|\hat{e}|$ scaling) while + physical nodes are plain moments: the edge-length Jacobian of the line integral is then + absorbed and no reference-edge-length bookkeeping or orientation logic is needed. + * High-order HCT/Argyris: edge normal-derivative moments are taken against Jacobi + polynomials $P_i^{(1,1)}$ (weights $(2,2)$ for Argyris, matching the vertex jet order), + so the edge-based functions are hierarchical. The tangential completion + $\mu^t_{e,i}$ integrates by parts against the *trace* moments (defined against + $\frac{d}{ds}P_i^{(1,1)}$): $\mu^t_{e,i}(f) = -\mu_{e,i}(f) + P_i^{(1,1)}(1)\, + \delta_{v_b}(f) - P_i^{(1,1)}(-1)\,\delta_{v_a}(f)$. This couples normal moments to + trace moments, not just vertex dofs. + +### Active work: automating the transformation (see `plan_zany_auto.md`) + +Goal: replace the hand-coded `basis_transformation` methods with a helper that derives +$V = E V^c D$ directly from a FIAT element's dual basis. The implementation must mirror +the theory factor by factor, not merely reproduce matrix entries: + +**Status (2026-07-13).** The symbolic dof lives in `finat/functional.py` as +`finat.PhysicallyMappedFunctional`. `finat/physically_mapped.py` stays fully generic: +`PhysicallyMappedElement` is unchanged from before this project, still an abstract +mixin with no knowledge of the zany theory, used as-is by hand-coded elements (AW, +HCT, PowellSabin, Walkington, ...). All the automation lives in `finat/zany.py`, as a +template method on `ZanyPhysicallyMappedElement(PhysicallyMappedElement)`: the +entity-by-entity assembly loop is implemented once, in its concrete +`basis_transformation`, calling four hooks (`_check_mapping`, `_invariant_dofs`, +`_facet_dof_rows`, `_point_dof_rows`) that carry ALL mapping-specific knowledge — the +loop itself contains no `if piola` anywhere. Two mixins implement those hooks, +`ScalarPhysicallyMappedElement` (affine pullback: Morley, Hermite, Argyris, Bell) and +`PiolaPhysicallyMappedElement` ((double) contravariant Piola: MTW, Johnson-Mercier, +Guzman-Neilan), plus the pure math functions they call (`FacetFrame`, +`_scalar_facet_rows`, `_scalar_point_rows`, `_piola_facet_rows`, `_piola_point_rows`) — +these take plain arrays/GEM expressions, no `self`, so the mathematics stays readable +independent of the class plumbing. Concrete elements (`finat.Morley`, etc.) are now +just a citation plus a FIAT constructor call: mixing in the right base class is enough, +`basis_transformation` is inherited (MRO example: `Morley -> ScalarPhysicallyMappedElement +-> ZanyPhysicallyMappedElement -> PhysicallyMappedElement -> ...`). `ndof` truncation is +no longer a parameter; the loop always slices by `self.space_dimension()`, which +constrained elements (Bell, GN) already override. Tests: +`test/finat/test_zany_automation.py`; `check_zany_mapping` lives in the finat conftest +and is provided to test modules as a pytest fixture (pytest runs with +`--import-mode=importlib`, so test modules cannot import from each other or from +conftest). + +**Framework design.** A dof is a symbolic `finat.PhysicallyMappedFunctional`: +$\ell(f) = \sum_q w_q \langle D, \nabla^m f(x_q)\rangle$ with numeric points/weights +and a direction tensor $D$ that is numeric on the reference cell and GEM otherwise. +There is *no dispatch over FIAT functional types*: `PhysicallyMappedFunctional.from_fiat` +reads only `pt_dict`/`deriv_dict` and recovers the order and common direction numerically +(rank-one SVD of the derivative weights). Operations: covariant `pullback(J)` +(contract direction slots with $J$), `with_direction`, and numeric `evaluate` against +a nodal basis (the generalized Vandermonde realizing the $D$ factor of $V = E V^c D$). +The row of $V$ for a reference node $\hat\ell$ with direction $\hat d = a\hat n + +\sum_k \beta_k \hat t_k$ (numeric split) is assembled generically: + +* $a = 0$ (value dofs, or tangential directions): push-forward invariant, identity row. +* otherwise solve $J\hat d = x_0 C + \sum_k x_k J\hat t_k$ symbolically + (`FacetFrame.decompose`, adjugate/determinant of the polynomial frame matrix), where + $C$ = generalized cross product of the mapped tangents. The physical node has + direction $aN + \sum\beta_k J\hat t_k$ with $N = \kappa C/\|C\|$, so its coefficient + is $c = x_0\|C\|/(\kappa a)$ and the tangential remainders are $r_k = x_k - c\beta_k$ + (careful: *not* $a x_k$ — the SVD direction may be $-\hat n$, and all formulas must be + invariant under that sign flip; a sign bug here flips exactly the edges where the SVD + chose the opposite orientation). +* the remainders multiply completion functionals along *mapped reference tangents*, + which coincide with reference functionals; `PhysicallyMappedFunctional.evaluate` gives their numeric + expansion in the element's own nodes, and the row combination recurses through the + already-assembled rows of $V$ (entities processed in increasing dimension), which + will later let completions couple to vertex jets (Argyris/HCT) for free. + +Derivative nodes *away from facets* (`_scalar_point_rows`, covering Hermite vertex +gradients) have no geometric frame: FIAT keeps Cartesian directions on the physical +cell, so the group of derivative nodes on the entity acts as its own completion — this +is precisely affine-interpolation equivalence. The pulled-back direction $J\hat d_i$ +is expanded in the group's own (numeric) direction basis, with weight-ratio factors +making the expansion invariant under the SVD scale/sign ambiguity of each node's +recovered $(w, D)$ factorization. The group must span the derivative jet, share its +points, and have pairwise-parallel weights; otherwise `NotImplementedError`. + +Key facts the framework rests on: + +* **FIAT normals are "UFC consistent":** computed from the tangents by the same formula + on reference and physical cells (`UFCTriangle.compute_normal` rotates the scaled edge + tangent; `UFCTetrahedron.compute_normal` is $-2\times$ the unit cross product of the + scaled face tangents), with cell-independent magnitude. Hence $N = \kappa C/\|C\|$ + with $\kappa$ recoverable from reference data, and no orientation logic is needed + (assumes $\det J > 0$). For the record, the fully simplified closed forms the solve + reproduces are $a = \det J\sqrt{\det\hat G/\det G}$ and $b = G^{-1}T^TJ\hat n$ with + Gram matrices of the (mapped) tangents. +* **Integral averages are push-forward invariant.** FIAT's Morley dofs are averages + (`FacetQuadratureRule(..., avg=True)`), so physical nodes share the reference weights + and no `physical_edge_lengths` appear. The framework *assumes* measure-intrinsic + moments (the Brubeck & Kirby 2025 reference-node convention) — documented in + `finat/functional.py`. +* **The conditioning $h$-scaling generalizes via `max_deriv_order`.** Column $j$ is + scaled by $h_E^{-m}$ where $m$ is the derivative order of node $j$ and $h_E$ averages + `cell_size()` over the vertices of its entity; reproduces every per-element + hand-written scaling. `cell_size()` returns raw numpy values in the test mappings but + GEM in Firedrake, so use operator arithmetic (`havg**(-m)`), not GEM constructors + (GEM overloads `+ - * / ** @` with `Zero`/constant folding; keep numpy object arrays + on the left when scaling by a GEM scalar). + +Extensions beyond first order and Morley/Hermite: + +* `PhysicallyMappedFunctional` directions live in derivative multi-index space (`multiindices`, axis + order for $m=1$); `pullback` distributes them over a symmetric tensor (dividing by + multiplicities), contracts every slot with $J$ (`numpy.tensordot` on object arrays), + and collapses back. Point-jet groups are split per order; each order solves in its + own multi-index direction basis (Argyris/Bell vertex jets: gradient + Hessian). +* Facet completions of Argyris edge moments couple to vertex jets and same-edge trace + moments; the existing row recursion handles both with no new code (trace moments are + order-0 and thus invariant; FIAT builds all these moments with + `FacetQuadratureRule(avg=True)`, i.e. measure-intrinsic, as the framework assumes). +* `ScalarPhysicallyMappedElement.avg = False` (an instance attribute Argyris sets from + its constructor kwarg) reproduces the legacy FInAT convention where physical facet + moments are plain integrals: their columns are divided by the physical facet measure + $\|C\| |\hat e| / \|\hat C\|$ (`FacetFrame.measure`). Single-point facet dofs + (Argyris "point" variant) are unaffected. +* Bell is the extended-element pattern: FIAT.Bell is the 21-node quintic element with + the constraint functionals as extra edge nodes; overriding `space_dimension()` to 18 + drops the constraint *columns* of $V$ at the end of the template method (their rows + still contribute the $D$-matrix entries through the completion recursion), and the + FInAT element overrides `entity_dofs`. +* Known convention change: the generic $h^{-m}$ conditioning scaling now also applies + to integral-variant Argyris edge moments, which the hand-written code left unscaled + (Morley scaled them; the legacy convention was inconsistent). Invisible when + `cell_size == 1`; flag in PR review. + +**Piola-mapped elements** (Aznaran, Kirby & Farrell 2022). `PhysicallyMappedFunctional` carries a +value rank: component weight profiles (nq x sd^rank) parsed from `pt_dict` component +tuples. Under contravariant Piola the roles of the scalar case are mirrored: the +*scaled* facet normal is the cofactor image $K\hat n_s$, $K = \mathrm{adj}(J)^T$ +(exactly the physical `compute_scaled_normal`, cross product of mapped tangents), so +pure normal moments are invariant, while scaled tangents map by $J$. `_piola_facet_rows` +works with per-point *frame-coordinate profiles* (handles 3D MTW's point-varying +RT-mapped tangential directions): the pulled-back profile is contracted per value slot +with the mixing matrix $Y$; tangential profiles are matched within the facet group by a +numeric pseudo-inverse; the residual normal profile is eliminated by per-point normal +moments through the Vandermonde recursion (this is where e.g. tangential-to-normal +couplings emerge). Key subtlety (in any dimension > 2): FIAT builds tangential value +components on the **reciprocal basis** (`cross(n, t_k)`), which transforms in-plane +contravariantly: absorb $S^{-1} = (\det\hat G_t/\det G_t)\hat G_t^{-1} G_t$ +(tangent Gram change) into $Y$'s tangential rows; in 2D $S = 1$, which hides the effect. +Interior value moments are Piola-invariant by construction (scaled-normal components for +JM; covariant Nedelec test fields for MTW order 2 cancel the contravariant trial +exactly). Double contravariant (tensor) elements use the same code: one contraction per +value slot. MTW (2D/3D) and Johnson-Mercier (2D/3D) are reimplemented this way, matching +the deleted hand code to machine precision; RaviartThomas-type elements come out as pure +identity (Piola-equivalent) automatically. + +GuzmanNeilanFirstKindH1 (orders 0-2, 2D/3D) is also automatic: vertex/edge point +values are value point-groups mapping by $K$ (`_piola_point_rows`, the mirror of +`_scalar_point_rows`), and the trailing tangential facet constraints are dropped since +`PiolaBubbleElement.space_dimension()` already returns the reduced count (the Bell +pattern, inherited rather than passed as a parameter); `PiolaBubbleElement.__init__` +still provides the reduced `entity_dofs` bookkeeping. `GuzmanNeilanFirstKindH1` is +`class GuzmanNeilanFirstKindH1(PiolaPhysicallyMappedElement, PiolaBubbleElement)`: MRO +puts the automatic `basis_transformation` first, while `PiolaBubbleElement.__init__` +(reached via `super()`) still sets up `self._element` and the reduced dof bookkeeping. Its hand-derived vertex-facet coupling correction ("fix +discrepancy" in `finat/piola_mapped.py`) emerges automatically from the Vandermonde +residual elimination, since the per-point normal moments evaluate against vertex basis +functions of the extended element. + +Next steps: remaining PiolaBubbleElement users (GN second kind, H1div: interior +derivative moments need the divergence detJ rule), ArnoldWinther (vertex tensor values ++ higher facet moments), the extended-element path for reduced HCT (macro polynomial +spaces); covariant elements. + +The implementation mirrors the theory factor by factor: + +1. **Sparsity from topology**: each row of $V$ is a reference node; its nonzero columns + are the physical nodes on the same entity (via $V^c$) plus the nodes on adjacent + entities pulled in by $D$ (e.g. edge rows couple to the edge's vertex dofs, and for + hierarchical elements to the same edge's trace moments). Derive this from + `entity_dofs()` and functional types (`FIAT/functional.py`), never hard-code indices. +2. **Completion from functional type**: a normal-derivative node's completion partner is + the corresponding tangential-derivative node; the completion is a per-entity statement, + determined by which components of the derivative jet the dual basis lacks. +3. **$D$ from unisolvence, not from closed-form rules**: the univariate exactness rules + (FTC, quintic endpoint rule, integration by parts against trace moments) are all + instances of one computation — express a completion functional applied to the + polynomial space in the basis of the element's own nodes, i.e. solve with the + generalized Vandermonde matrix. This is where GEM-based dual evaluation comes in. +4. Constrained spaces (reduced HCT, Bell) reuse the same machinery on the extended + element; the FIAT element must expose the constraint functionals as extra dofs. From 2a209fed93eb2ab962c8b1a9f1f134eeafb8b921 Mon Sep 17 00:00:00 2001 From: Pablo Brubeck Date: Tue, 14 Jul 2026 08:11:23 +0100 Subject: [PATCH 16/34] Replace pinv with a Gram-matrix solve in _piola_facet_rows numpy.linalg.pinv silently returns a least-squares fit if the group's tangential profiles are not actually independent; solving the small, square Gram system B @ B.T instead fails loudly on such a bug since B has full row rank by unisolvence. Document why the per-point frame coordinate profile (not just a tangent direction) is needed to tell facet dofs apart. --- finat/zany.py | 32 +++++++++++++++++++++----------- zany_claude.md | 11 +++++++---- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/finat/zany.py b/finat/zany.py index 4ba0b3e26..31838ca2c 100644 --- a/finat/zany.py +++ b/finat/zany.py @@ -466,12 +466,15 @@ def _piola_facet_rows(V: numpy.ndarray, group: dict, map the scaled facet normal is the image of the reference one under the cofactor matrix :math:`K = \operatorname{adj}(J)^T`, so pure normal moments are invariant, while the scaled tangents map by - :math:`J`. Each node carries a per-point profile of frame - coordinates, shared by its physical counterpart; the pulled-back - reference node mixes the coordinates through the frame expansion of - :math:`K\hat{t}_k`, the tangential profiles are matched within the - group, and the residual normal profile is eliminated numerically - through already assembled rows of V. + :math:`J`. Distinct nodes on a facet can share the same tangential + directions (e.g. two RT-type facet dofs in 3D) and are only told + apart by how their weight varies from point to point, so a node is + identified by its full per-point profile of frame coordinates, not + by direction alone; the pulled-back reference node mixes the + coordinates through the frame expansion of :math:`K\hat{t}_k`, the + tangential profiles are matched within the group, and the residual + normal profile is eliminated numerically through already assembled + rows of V. :arg V: Object array being assembled. :arg group: Mapping from node index to symbolic PhysicallyMappedFunctional @@ -493,7 +496,11 @@ def _piola_facet_rows(V: numpy.ndarray, group: dict, dtype=object) K = adjugate(Jnp).T - # Reference frame coordinate profiles, shared with the physical nodes + # Reference frame coordinate profiles, shared with the physical nodes: + # each node's own quadrature weights, decomposed into (normal, + # tangential) frame coordinates at each of its points. This is what + # distinguishes nodes with the same tangential directions from one + # another (see the point-dependence note in the docstring above). coords = {} for i, ell in group.items(): C = ell.weights.reshape(-1, *(sd,) * ell.rank) @@ -533,10 +540,13 @@ def _piola_facet_rows(V: numpy.ndarray, group: dict, (numpy.linalg.det(Ghat_t) / determinant(G)) Y[1:, :] = Sinv @ Y[1:, :] - # Numeric matching of the tangential coordinate profiles in the group + # Numeric matching of the tangential coordinate profiles in the group. + # B has full row rank by unisolvence, so the Gram matrix B @ B.T is + # square and invertible; a rank deficiency (a genuine bug) surfaces + # as a LinAlgError here rather than a silent least-squares fit. B = numpy.array([coords[j][:, 1:].ravel() for j in group]) - Bpinv = numpy.linalg.pinv(B) - Bpinv[abs(Bpinv) < tol] = 0 + Binv = numpy.linalg.inv(B @ B.T) @ B + Binv[abs(Binv) < tol] = 0 # Numeric elimination of the normal profile: one pure normal moment # per quadrature point, evaluated on the nodal basis @@ -556,7 +566,7 @@ def _piola_facet_rows(V: numpy.ndarray, group: dict, P = P.reshape(len(points), -1) row = numpy.full(V.shape[1], Zero(), dtype=object) - c = Bpinv.T @ P[:, 1:].ravel() + c = Binv @ P[:, 1:].ravel() for cj, j in zip(c, group): row[j] = cj # Residual normal profile after removing the group contribution diff --git a/zany_claude.md b/zany_claude.md index ec1dc4617..de39d5edd 100644 --- a/zany_claude.md +++ b/zany_claude.md @@ -180,10 +180,13 @@ tuples. Under contravariant Piola the roles of the scalar case are mirrored: the pure normal moments are invariant, while scaled tangents map by $J$. `_piola_facet_rows` works with per-point *frame-coordinate profiles* (handles 3D MTW's point-varying RT-mapped tangential directions): the pulled-back profile is contracted per value slot -with the mixing matrix $Y$; tangential profiles are matched within the facet group by a -numeric pseudo-inverse; the residual normal profile is eliminated by per-point normal -moments through the Vandermonde recursion (this is where e.g. tangential-to-normal -couplings emerge). Key subtlety (in any dimension > 2): FIAT builds tangential value +with the mixing matrix $Y$; tangential profiles are matched within the facet group by +solving the (small, square) Gram system $B B^T c = B\cdot(\text{target})$, where $B$ +stacks the group's own reference tangential profiles and has full row rank by +unisolvence, so $B B^T$ is invertible and a rank deficiency (a genuine bug) surfaces as +a hard numerical error rather than passing silently; the residual normal profile is +eliminated by per-point normal moments through the Vandermonde recursion (this is where +e.g. tangential-to-normal couplings emerge). Key subtlety (in any dimension > 2): FIAT builds tangential value components on the **reciprocal basis** (`cross(n, t_k)`), which transforms in-plane contravariantly: absorb $S^{-1} = (\det\hat G_t/\det G_t)\hat G_t^{-1} G_t$ (tangent Gram change) into $Y$'s tangential rows; in 2D $S = 1$, which hides the effect. From 2d156f01aa8f640705ae9bca6d7fe31a6f9e08a0 Mon Sep 17 00:00:00 2001 From: Pablo Brubeck Date: Mon, 13 Jul 2026 23:41:06 +0100 Subject: [PATCH 17/34] split AGENTS.md --- AGENTS.md | 409 +++--------------------------------------------- finat/AGENTS.md | 101 ++++++++++++ gem/AGENTS.md | 29 ++++ zany_claude.md | 231 +++++++++++++++++++++++++++ 4 files changed, 383 insertions(+), 387 deletions(-) create mode 100644 finat/AGENTS.md create mode 100644 gem/AGENTS.md create mode 100644 zany_claude.md diff --git a/AGENTS.md b/AGENTS.md index 029f3bdd3..aa3b37756 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,18 +9,7 @@ This document outlines the guidelines and architectural context for AI agents as When assisting with contributions to FIAT and the Firedrake project, AI agents and their human counterparts must adhere to the following strict policies: * The use of AI tools must be explicitly declared alongside the specific tool used. -* A human developer must lead the Pull Request. -* The human contributor must understand every change made to the codebase. * Reviewer questions must be answered directly by the human, rather than acting as a relay to the AI. -* Any generated code must be executed locally to verify that it functions correctly. -* AI tools must not be used to resolve issues that are labeled as 'good first issue'. - -### PR expectations - -* CI (`.github/workflows/test.yml`) checks `flake8` and `pydocstyle .` (both - configured in `setup.cfg`); run them locally and fix all findings before pushing. -* Watch for `pydocstyle` D413 (blank line after the last numpydoc section) and D417 - (every argument, including keyword arguments, needs a description) in new docstrings. --- @@ -43,7 +32,6 @@ Despite being called "fiat", this repository (Python package `firedrake_fiat`) c * `finat/` — symbolic layer on top of FIAT. Physically mapped elements (`finat/physically_mapped.py`, `finat/argyris.py`, `finat/morley.py`, `finat/hct.py`, `finat/piola_mapped.py`, …) construct GEM expressions for basis transformations. * `gem/` — the tensor-algebra intermediate language used to express transformations symbolically (`gem/gem.py` for nodes, `gem/interpreter.py` to evaluate expressions numerically in tests). * `test/` — note the singular name: tests live at `test/finat/test_zany_mapping.py`, `test/FIAT/...`, `test/gem/...` (not `tests/`). -* `literature/` — untracked folder with LaTeX sources of the two theory papers: `Kirby2017Transformation/paper.tex` (A general approach to transforming finite elements) and `BrubeckKirby2025Macroelements/paper.tex` (transformation theory for macroelements, §"Transformation theory"). ## Environment and Setup @@ -67,6 +55,28 @@ Agents modifying FIAT code must follow these fundamental development principles: * Code documentation and comments must explain the present, correct code. * Comments must not detail what a removed or incorrect approach previously did. +## Style and Conventions + +When writing Python code for FIAT, maintain the ecosystem's structural and stylistic integrity: + +* Class attributes must be declared in one visible location. +* Attributes must be initialized in the `__init__` constructor or declared as a `functools.cached_property` if they are expensive to compute. +* Ad hoc lazy initialization discovering attributes via `hasattr`, `setattr`, or `getattr` scattered across methods is strictly prohibited. +* Boolean attributes must be used to record initialization intent and state instead of probing for the presence of state built by an initialization function. +* New code must include type hints on all function and method signatures. +* Public-facing APIs must include properly formatted `numpydoc`-style docstrings. + +## Pull Request Expectations + +* All changes are expected to arrive through GitHub Pull Requests. +* Keep diffs reviewable and focused. +* Before concluding work verify that the relevant subset of the pytest test suite + succeeds locally. +* CI (`.github/workflows/test.yml`) checks `flake8` and `pydocstyle .` (both + configured in `setup.cfg`); run them locally and fix all findings before pushing. +* Watch for `pydocstyle` D413 (blank line after the last numpydoc section) and D417 + (every argument, including keyword arguments, needs a description) in new docstrings. + --- ## Pattern Matching and Mathematical Reasoning @@ -77,97 +87,6 @@ experience of automating the Kirby (2017) / Aznaran-Kirby-Farrell (2022) / Brube Argyris, Bell, Mardal-Tai-Winther, Johnson-Mercier, and Guzman-Neilan. The lessons below are about *how to design and debug this kind of code*, not just about this one project. -### Mathematical structures to recognize in FIAT/FInAT - -* **A degree of freedom is fully described by five numbers, not by its FIAT class.** Every - functional FIAT builds from `pt_dict`/`deriv_dict` reduces to (points, weights, derivative - order $m$, a direction tensor of rank $m$, a value rank for vector/tensor-valued dofs). - `IntegralMomentOfNormalDerivative`, `PointNormalDerivative`, `TensorBidirectionalIntegralMoment`, - etc. are just different ways of *constructing* the same five numbers. Recognizing this - collapses "N functional types to support" into "one shape to recover numerically" - (`finat.PhysicallyMappedFunctional.from_fiat`, `finat/functional.py`). -* **Pullback is always "contract each tensor slot of the direction with a fixed matrix."** - Order-0 (values) are invariant (zero slots to contract). Order-$m$ derivatives contract $m$ - slots with the Jacobian $J$ (the chain rule). Rank-$r$ Piola values contract $r$ slots with - the cofactor matrix $K = \operatorname{adj}(J)^T$. This is *the same operation* with a - different matrix, which is why the scalar and Piola code in `finat/zany.py` are mirror images - of each other (`_scalar_point_rows` / `_piola_point_rows`, `_scalar_facet_rows` / - `_piola_facet_rows`) rather than unrelated implementations. -* **Frame decomposition is the one computational primitive underlying every non-affine - element.** Facet dofs (Morley/Argyris/Bell normal derivatives; MTW/JM/GN normal-tangential - moments) and vertex-jet completions (Hermite/Argyris/Bell gradients and Hessians) are all - solved the *same* way: split a direction/profile into an invariant part and a part that needs - completing, express the pulled-back quantity in the frame built from the mapped generators of - that split (`FacetFrame`, or the direction-basis inverse in `_scalar_point_rows`), and solve - symbolically via `adjugate`/`determinant`. Once this pattern is visible, "add a new element" - stops being "derive new math" and becomes "which invariant subspace, which frame." -* **Constrained/extended elements are the same construction as a restriction.** Bell and - Guzman-Neilan are both "take the extended FIAT element with its constraint functionals as - extra dofs, transform the whole thing, then keep only the first $\nu$ columns." This is - Kirby (2017) §5's extended-element proposition, and in code it is nothing more than - `space_dimension()` returning a smaller count than the FIAT element's — no special-casing - needed in the transformation loop itself. - -### Confusing mathematical ideas, clarified - -* **The Jacobian direction is flipped between the papers and the code.** The papers define - $F$ from physical to reference space, so their $J$ is FInAT's Jacobian *inverse*. - `coordinate_mapping.jacobian_at(point)` returns $\partial x_{\text{phys}}/\partial - x_{\text{ref}}$ (see "FInAT implementation conventions" below); a paper's $J^{-T}$ is FInAT's - plain Jacobian, transposed. This is a constant source of "why does my formula look - transposed" confusion — check this convention flip before suspecting a sign or index bug. -* **Components against a reciprocal/dual basis transform contragrediently — this is the single - subtlety that broke 3D and is not in any of the papers.** FIAT builds the tangential - component of 3D Piola-mapped dofs (MTW) using `cross(n, t_k)`, the *reciprocal* partner of - the tangent frame, not the tangent frame itself. A general fact from differential geometry: - if a frame transforms by a matrix $A$, components expressed against its *reciprocal* frame - transform by $A^{-T}$ (up to a determinant factor), not by $A$. Kirby (2017)/Aznaran-Kirby- - Farrell (2022) only work out the 2D case, where the tangent "plane" is 1-dimensional and this - correction $S^{-1}$ collapses to $1$ — silently hiding the effect. **Whenever a FIAT dual set - builds a direction via a cross product against the normal (a common way to get "the other - in-plane directions" in 3D), check whether its transformation law is contragredient before - assuming it matches the direct frame.** -* **Numerically recovered invariants (SVD, pinv) are only defined up to a group action, and - formulas built from them must be invariant under that action.** `from_fiat`'s SVD recovers a - direction/weight pair $(\hat n, w)$ up to a joint sign flip ($\hat n \to -\hat n$, $w \to - -w$ leaves the functional unchanged). A formula like $r_k = a x_k + (1-c)\beta_k$ that - implicitly assumes a particular sign will be wrong on exactly the subset of entities where - SVD happened to pick the other sign — a bug that looks like it "mostly works" and is - otherwise very hard to localize. The fix, $r_k = x_k - c\beta_k$, is invariant under the - joint flip. **Always test the full topology of a non-degenerate, non-symmetric physical - cell** (see `test/finat/conftest.py::MyMapping`'s deliberately irregular vertex coordinates) — - a symmetric test cell can accidentally hide a sign bug that only shows up on a generic mesh. - -### Confusing GEM patterns, clarified - -* **Use operator overloading, not manual node construction.** `gem.Node` overloads - `+ - * / ** @` (via `as_gem`/`componentwise`) to work transparently across GEM nodes, - `gem.Literal`, and plain Python/numpy numbers, with automatic `Zero` folding. Write - `havg**(-m)`, never `gem.Power(havg, gem.Literal(-m))`: the operator form also works when - `havg` is a raw float (as in the test harness's `MyMapping.cell_size()`) whereas a manual - `gem.Power` call assumes GEM operands and will not always coerce correctly. -* **Never call `numpy.linalg.inv`/`solve` on a GEM-valued matrix.** LAPACK cannot see inside - GEM expressions. Symbolic linear solves use `adjugate(A) / determinant(A)` - (`finat/physically_mapped.py`) — the symbolic Cramer's-rule equivalent — e.g. in - `FacetFrame.decompose` and `_piola_facet_rows`. Numeric `numpy.linalg` calls are only valid - when every entry is a plain number: reference-cell-only quantities - (`FacetFrame.reference_coefficients`) or purely numeric direction-basis inversions - (`_scalar_point_rows`, `_piola_point_rows`). Check which regime an array is in before - reaching for a numpy linear-algebra routine. -* **Build sparse GEM-valued arrays with `numpy.full(shape, gem.Zero(), dtype=object)`**, never - `numpy.zeros(shape)` — plain `0` is not interchangeable with `gem.Zero()` when the array will - later be combined with GEM nodes via `+`. -* **"Start from identity, mutate only the rows that need work" is not an optimization, it is - the mathematical content.** `V = identity(ndof)` (`finat/physically_mapped.py`) encodes - "these dofs are push-forward invariant" directly; the invariant-dof detection - (`_invariant_dofs`) simply chooses *not* to touch those rows, rather than writing `1`s - explicitly. Treat the untouched identity rows as the base case of the assembly recursion. -* **Row/column convention, and where the one transpose happens.** Throughout assembly, row - index = reference node, column index = physical node — i.e. the code builds $V$, never $M$ - directly. `ListTensor(V.T)` at the very end is the single place Kirby (2017) Theorem 3.1's - $M = V^T$ gets applied. If something looks transposed, check this convention before - suspecting a sign error. - ### Design strategies that generalize * **Design by duality, never by direct construction.** Do not try to write physical basis @@ -232,287 +151,3 @@ about *how to design and debug this kind of code*, not just about this one proje * **Keep this file's *why*, not just the *what*.** Git history has the diffs; this file exists so the next session does not have to rediscover the reciprocal-basis subtlety, the sign-invariance requirement, or the identity-row idiom by re-reading a diff from scratch. - -## Transformation Theory (Kirby 2017; Brubeck & Kirby 2025) - -The mathematical framework for mapping non-affinely-equivalent elements. Notation: the -geometric map goes **from physical to reference**, $F: K \to \hat{K}$; pullback -$F^*(\hat{f}) = \hat{f}\circ F$; push-forward of a node $F_*(n) = n \circ F^*$. - -* Goal: the matrix $M$ with $\Psi = M\, F^*(\hat\Psi)$, expressing physical nodal basis - functions as combinations of pulled-back reference basis functions. -* Duality is the whole game: with $B_{ij} = F_*(n_i)(\hat\psi_j) = n_i(F^*(\hat\psi_j))$, - the Kronecker property gives $M = B^{-T}$ and $V = B^{-1}$, where $V$ relates nodes: - $\hat{N} = V\, F_*(N)$ (restricted to $P$). Hence **$M = V^T$**, and $V$ is what one - actually constructs, because push-forwards of nodes are computable by the chain rule. -* Affine equivalence (Lagrange): $F_*(N) = \hat{N}$, so $M = I$. -* Affine-interpolation equivalence (Hermite): spans of nodes at each point are preserved, - so $V$ is block diagonal, one small block per point-node group (e.g. $J^{-T}$ blocks for - vertex gradients — in the *paper's* $J$; see the FInAT convention below). -* Neither (Morley, Argyris, HCT): edge normal derivatives alone do not push forward into - the span of reference nodes. Fix by a **compatible nodal completion** $N^c \supset N$, - $\hat{N}^c \supset \hat{N}$ with $\mathrm{span}(F_*(N^c)) = \mathrm{span}(\hat{N}^c)$ - (add the tangential-derivative partners so each edge carries a full gradient). Then - $$V = E\, V^c\, D,$$ - with the three factors having distinct mathematical roles: - * $D \in \mathbb{R}^{\mu\times\nu}$: expresses the completed physical nodes in terms of - the actual physical nodes, *restricted to* $P$ ($\pi N^c = D\, \pi N$). Rows for nodes - already in $N$ are Boolean. Rows for completion nodes come from a univariate exactness - argument on the edge: a tangential-derivative quantity of a polynomial of known edge - degree is an exact linear combination of the endpoint values/derivatives (FTC for HCT's - $\mu^t_e$; the quintic endpoint rule for Argyris/Bell midpoint tangential derivatives). - This is the only factor that uses the polynomial degree of the space. - * $V^c \in \mathbb{R}^{\mu\times\mu}$: block diagonal, pure chain rule, - $\hat{N}^c = V^c F_*(N^c)$. Vertex value $\to 1$; vertex gradient $\to$ Jacobian block; - vertex Hessian $\to$ symmetric-square block $\Theta$; edge normal/tangential pair $\to$ - the $2\times2$ block $B_i = \hat{G}_i J^{-T} G_i^T$ (paper's $J$), where - $G = [\mathbf{n}\; \mathbf{t}]^T$. Only the first row of $B_i$ is needed - ($B_{nn}$, $B_{nt}$) since tangential rows are discarded by $E$. - * $E \in \mathbb{R}^{\nu\times\mu}$: Boolean extraction of $\hat{N}$ from $\hat{N}^c$. -* Constrained spaces, $F^*(\hat{P}) \neq P$ (Bell, reduced HCT): the space is - $P = \bigcap_i \mathrm{null}(\lambda_i)$ inside a larger $\tilde{P}$ that *is* preserved - (e.g. reduced HCT = HCT functions whose edge normal derivative is linear; - $\lambda_i$ = moment of the normal derivative against the quadratic Legendre polynomial - on edge $i$). Build the **extended element** $(K, \tilde{P}, [N; L])$ with the - constraints $\lambda_i$ appended as extra nodes; it is a valid finite element, and its - first $\nu$ nodal basis functions are exactly the nodal basis of the constrained - element. Transform the extended element with the $E V^c D$ machinery (completing each - $\lambda_i$ with its tangential partner $\lambda_i'$, again eliminated through an edge - exactness rule), then discard the constraint rows. -* Brubeck & Kirby 2025 refinements: - * Define *reference* edge nodes as integral **averages** ($1/|\hat{e}|$ scaling) while - physical nodes are plain moments: the edge-length Jacobian of the line integral is then - absorbed and no reference-edge-length bookkeeping or orientation logic is needed. - * High-order HCT/Argyris: edge normal-derivative moments are taken against Jacobi - polynomials $P_i^{(1,1)}$ (weights $(2,2)$ for Argyris, matching the vertex jet order), - so the edge-based functions are hierarchical. The tangential completion - $\mu^t_{e,i}$ integrates by parts against the *trace* moments (defined against - $\frac{d}{ds}P_i^{(1,1)}$): $\mu^t_{e,i}(f) = -\mu_{e,i}(f) + P_i^{(1,1)}(1)\, - \delta_{v_b}(f) - P_i^{(1,1)}(-1)\,\delta_{v_a}(f)$. This couples normal moments to - trace moments, not just vertex dofs. - -### Active work: automating the transformation (see `plan_zany_auto.md`) - -Goal: replace the hand-coded `basis_transformation` methods with a helper that derives -$V = E V^c D$ directly from a FIAT element's dual basis. The implementation must mirror -the theory factor by factor, not merely reproduce matrix entries: - -**Status (2026-07-13).** The symbolic dof lives in `finat/functional.py` as -`finat.PhysicallyMappedFunctional`. `finat/physically_mapped.py` stays fully generic: -`PhysicallyMappedElement` is unchanged from before this project, still an abstract -mixin with no knowledge of the zany theory, used as-is by hand-coded elements (AW, -HCT, PowellSabin, Walkington, ...). All the automation lives in `finat/zany.py`, as a -template method on `ZanyPhysicallyMappedElement(PhysicallyMappedElement)`: the -entity-by-entity assembly loop is implemented once, in its concrete -`basis_transformation`, calling four hooks (`_check_mapping`, `_invariant_dofs`, -`_facet_dof_rows`, `_point_dof_rows`) that carry ALL mapping-specific knowledge — the -loop itself contains no `if piola` anywhere. Two mixins implement those hooks, -`ScalarPhysicallyMappedElement` (affine pullback: Morley, Hermite, Argyris, Bell) and -`PiolaPhysicallyMappedElement` ((double) contravariant Piola: MTW, Johnson-Mercier, -Guzman-Neilan), plus the pure math functions they call (`FacetFrame`, -`_scalar_facet_rows`, `_scalar_point_rows`, `_piola_facet_rows`, `_piola_point_rows`) — -these take plain arrays/GEM expressions, no `self`, so the mathematics stays readable -independent of the class plumbing. Concrete elements (`finat.Morley`, etc.) are now -just a citation plus a FIAT constructor call: mixing in the right base class is enough, -`basis_transformation` is inherited (MRO example: `Morley -> ScalarPhysicallyMappedElement --> ZanyPhysicallyMappedElement -> PhysicallyMappedElement -> ...`). `ndof` truncation is -no longer a parameter; the loop always slices by `self.space_dimension()`, which -constrained elements (Bell, GN) already override. Tests: -`test/finat/test_zany_automation.py`; `check_zany_mapping` lives in the finat conftest -and is provided to test modules as a pytest fixture (pytest runs with -`--import-mode=importlib`, so test modules cannot import from each other or from -conftest). - -**Framework design.** A dof is a symbolic `finat.PhysicallyMappedFunctional`: -$\ell(f) = \sum_q w_q \langle D, \nabla^m f(x_q)\rangle$ with numeric points/weights -and a direction tensor $D$ that is numeric on the reference cell and GEM otherwise. -There is *no dispatch over FIAT functional types*: `PhysicallyMappedFunctional.from_fiat` -reads only `pt_dict`/`deriv_dict` and recovers the order and common direction numerically -(rank-one SVD of the derivative weights). Operations: covariant `pullback(J)` -(contract direction slots with $J$), `with_direction`, and numeric `evaluate` against -a nodal basis (the generalized Vandermonde realizing the $D$ factor of $V = E V^c D$). -The row of $V$ for a reference node $\hat\ell$ with direction $\hat d = a\hat n + -\sum_k \beta_k \hat t_k$ (numeric split) is assembled generically: - -* $a = 0$ (value dofs, or tangential directions): push-forward invariant, identity row. -* otherwise solve $J\hat d = x_0 C + \sum_k x_k J\hat t_k$ symbolically - (`FacetFrame.decompose`, adjugate/determinant of the polynomial frame matrix), where - $C$ = generalized cross product of the mapped tangents. The physical node has - direction $aN + \sum\beta_k J\hat t_k$ with $N = \kappa C/\|C\|$, so its coefficient - is $c = x_0\|C\|/(\kappa a)$ and the tangential remainders are $r_k = x_k - c\beta_k$ - (careful: *not* $a x_k$ — the SVD direction may be $-\hat n$, and all formulas must be - invariant under that sign flip; a sign bug here flips exactly the edges where the SVD - chose the opposite orientation). -* the remainders multiply completion functionals along *mapped reference tangents*, - which coincide with reference functionals; `PhysicallyMappedFunctional.evaluate` gives their numeric - expansion in the element's own nodes, and the row combination recurses through the - already-assembled rows of $V$ (entities processed in increasing dimension), which - will later let completions couple to vertex jets (Argyris/HCT) for free. - -Derivative nodes *away from facets* (`_scalar_point_rows`, covering Hermite vertex -gradients) have no geometric frame: FIAT keeps Cartesian directions on the physical -cell, so the group of derivative nodes on the entity acts as its own completion — this -is precisely affine-interpolation equivalence. The pulled-back direction $J\hat d_i$ -is expanded in the group's own (numeric) direction basis, with weight-ratio factors -making the expansion invariant under the SVD scale/sign ambiguity of each node's -recovered $(w, D)$ factorization. The group must span the derivative jet, share its -points, and have pairwise-parallel weights; otherwise `NotImplementedError`. - -Key facts the framework rests on: - -* **FIAT normals are "UFC consistent":** computed from the tangents by the same formula - on reference and physical cells (`UFCTriangle.compute_normal` rotates the scaled edge - tangent; `UFCTetrahedron.compute_normal` is $-2\times$ the unit cross product of the - scaled face tangents), with cell-independent magnitude. Hence $N = \kappa C/\|C\|$ - with $\kappa$ recoverable from reference data, and no orientation logic is needed - (assumes $\det J > 0$). For the record, the fully simplified closed forms the solve - reproduces are $a = \det J\sqrt{\det\hat G/\det G}$ and $b = G^{-1}T^TJ\hat n$ with - Gram matrices of the (mapped) tangents. -* **Integral averages are push-forward invariant.** FIAT's Morley dofs are averages - (`FacetQuadratureRule(..., avg=True)`), so physical nodes share the reference weights - and no `physical_edge_lengths` appear. The framework *assumes* measure-intrinsic - moments (the Brubeck & Kirby 2025 reference-node convention) — documented in - `finat/functional.py`. -* **The conditioning $h$-scaling generalizes via `max_deriv_order`.** Column $j$ is - scaled by $h_E^{-m}$ where $m$ is the derivative order of node $j$ and $h_E$ averages - `cell_size()` over the vertices of its entity; reproduces every per-element - hand-written scaling. `cell_size()` returns raw numpy values in the test mappings but - GEM in Firedrake, so use operator arithmetic (`havg**(-m)`), not GEM constructors - (GEM overloads `+ - * / ** @` with `Zero`/constant folding; keep numpy object arrays - on the left when scaling by a GEM scalar). - -Extensions beyond first order and Morley/Hermite: - -* `PhysicallyMappedFunctional` directions live in derivative multi-index space (`multiindices`, axis - order for $m=1$); `pullback` distributes them over a symmetric tensor (dividing by - multiplicities), contracts every slot with $J$ (`numpy.tensordot` on object arrays), - and collapses back. Point-jet groups are split per order; each order solves in its - own multi-index direction basis (Argyris/Bell vertex jets: gradient + Hessian). -* Facet completions of Argyris edge moments couple to vertex jets and same-edge trace - moments; the existing row recursion handles both with no new code (trace moments are - order-0 and thus invariant; FIAT builds all these moments with - `FacetQuadratureRule(avg=True)`, i.e. measure-intrinsic, as the framework assumes). -* `ScalarPhysicallyMappedElement.avg = False` (an instance attribute Argyris sets from - its constructor kwarg) reproduces the legacy FInAT convention where physical facet - moments are plain integrals: their columns are divided by the physical facet measure - $\|C\| |\hat e| / \|\hat C\|$ (`FacetFrame.measure`). Single-point facet dofs - (Argyris "point" variant) are unaffected. -* Bell is the extended-element pattern: FIAT.Bell is the 21-node quintic element with - the constraint functionals as extra edge nodes; overriding `space_dimension()` to 18 - drops the constraint *columns* of $V$ at the end of the template method (their rows - still contribute the $D$-matrix entries through the completion recursion), and the - FInAT element overrides `entity_dofs`. -* Known convention change: the generic $h^{-m}$ conditioning scaling now also applies - to integral-variant Argyris edge moments, which the hand-written code left unscaled - (Morley scaled them; the legacy convention was inconsistent). Invisible when - `cell_size == 1`; flag in PR review. - -**Piola-mapped elements** (Aznaran, Kirby & Farrell 2022). `PhysicallyMappedFunctional` carries a -value rank: component weight profiles (nq x sd^rank) parsed from `pt_dict` component -tuples. Under contravariant Piola the roles of the scalar case are mirrored: the -*scaled* facet normal is the cofactor image $K\hat n_s$, $K = \mathrm{adj}(J)^T$ -(exactly the physical `compute_scaled_normal`, cross product of mapped tangents), so -pure normal moments are invariant, while scaled tangents map by $J$. `_piola_facet_rows` -works with per-point *frame-coordinate profiles* (handles 3D MTW's point-varying -RT-mapped tangential directions): the pulled-back profile is contracted per value slot -with the mixing matrix $Y$; tangential profiles are matched within the facet group by a -numeric pseudo-inverse; the residual normal profile is eliminated by per-point normal -moments through the Vandermonde recursion (this is where e.g. tangential-to-normal -couplings emerge). Key subtlety (in any dimension > 2): FIAT builds tangential value -components on the **reciprocal basis** (`cross(n, t_k)`), which transforms in-plane -contravariantly: absorb $S^{-1} = (\det\hat G_t/\det G_t)\hat G_t^{-1} G_t$ -(tangent Gram change) into $Y$'s tangential rows; in 2D $S = 1$, which hides the effect. -Interior value moments are Piola-invariant by construction (scaled-normal components for -JM; covariant Nedelec test fields for MTW order 2 cancel the contravariant trial -exactly). Double contravariant (tensor) elements use the same code: one contraction per -value slot. MTW (2D/3D) and Johnson-Mercier (2D/3D) are reimplemented this way, matching -the deleted hand code to machine precision; RaviartThomas-type elements come out as pure -identity (Piola-equivalent) automatically. - -GuzmanNeilanFirstKindH1 (orders 0-2, 2D/3D) is also automatic: vertex/edge point -values are value point-groups mapping by $K$ (`_piola_point_rows`, the mirror of -`_scalar_point_rows`), and the trailing tangential facet constraints are dropped since -`PiolaBubbleElement.space_dimension()` already returns the reduced count (the Bell -pattern, inherited rather than passed as a parameter); `PiolaBubbleElement.__init__` -still provides the reduced `entity_dofs` bookkeeping. `GuzmanNeilanFirstKindH1` is -`class GuzmanNeilanFirstKindH1(PiolaPhysicallyMappedElement, PiolaBubbleElement)`: MRO -puts the automatic `basis_transformation` first, while `PiolaBubbleElement.__init__` -(reached via `super()`) still sets up `self._element` and the reduced dof bookkeeping. Its hand-derived vertex-facet coupling correction ("fix -discrepancy" in `finat/piola_mapped.py`) emerges automatically from the Vandermonde -residual elimination, since the per-point normal moments evaluate against vertex basis -functions of the extended element. - -Next steps: remaining PiolaBubbleElement users (GN second kind, H1div: interior -derivative moments need the divergence detJ rule), ArnoldWinther (vertex tensor values -+ higher facet moments), the extended-element path for reduced HCT (macro polynomial -spaces); covariant elements. - -The implementation mirrors the theory factor by factor: - -1. **Sparsity from topology**: each row of $V$ is a reference node; its nonzero columns - are the physical nodes on the same entity (via $V^c$) plus the nodes on adjacent - entities pulled in by $D$ (e.g. edge rows couple to the edge's vertex dofs, and for - hierarchical elements to the same edge's trace moments). Derive this from - `entity_dofs()` and functional types (`FIAT/functional.py`), never hard-code indices. -2. **Completion from functional type**: a normal-derivative node's completion partner is - the corresponding tangential-derivative node; the completion is a per-entity statement, - determined by which components of the derivative jet the dual basis lacks. -3. **$D$ from unisolvence, not from closed-form rules**: the univariate exactness rules - (FTC, quintic endpoint rule, integration by parts against trace moments) are all - instances of one computation — express a completion functional applied to the - polynomial space in the basis of the element's own nodes, i.e. solve with the - generalized Vandermonde matrix. This is where GEM-based dual evaluation comes in. -4. Constrained spaces (reduced HCT, Bell) reuse the same machinery on the extended - element; the FIAT element must expose the constraint functionals as extra dofs. - -### FInAT implementation conventions - -* `coordinate_mapping.jacobian_at(point)` returns $\partial x_{\text{phys}} / \partial - x_{\text{ref}}$ — the **inverse** of the papers' $J$ (papers map physical → reference). - So where the paper writes $J^{-T}$, the code uses the FInAT Jacobian transposed; e.g. - Hermite's vertex blocks are `J[j, k]` entries of $M$ directly. -* `basis_transformation` builds the numpy object array `V` (row = reference node, column - = physical node, entries are GEM scalars) and returns `gem.ListTensor(V.T)` = $M$. - Start from `identity(ndof)` (in `finat/physically_mapped.py`) and overwrite the - non-identity rows. -* Existing generalized helpers, all in `finat/argyris.py`: - * `_jet_transform(J, order)`: chain-rule block for a symmetric derivative jet of any - order (order 1 → Jacobian, order 2 → $\Theta$, …), handling symmetric-component - flattening. - * `_vertex_transform(V, vorder, cell, mapping)`: places jet blocks for every vertex. - * `_normal_tangential_transform(cell, J, detJ, edge)`: returns $(B_{nn}, B_{nt}, Jt)$ - for an edge, expressing $B$ entries via $G^{TT}$ Gram data so only GEM-representable - quantities appear (`detJ/beta`, `alpha/beta`); 3D variant for faces is - `morley_transform` in `finat/walkington.py` (Morley itself is automated now; this - 3D helper survives only as Walkington's dependency). - * `_edge_transform(V, vorder, eorder, cell, mapping, avg)`: the Jacobi-moment edge rows - for integral-variant Argyris/HCT, encoding the endpoint values - $P_i^{(1,1)}(\pm 1)$ and the trace-moment coupling. -* Reduced/constrained elements (`ReducedHsiehCloughTocher` in `finat/hct.py`, `Bell`): - the FIAT element is the *extended* element (12 basis functions for reduced HCT), the - FInAT element exposes only $\nu$ dofs: `V = identity(numbf, ndof)` is rectangular, - `entity_dofs()` is overridden to empty the constrained entities, and - `space_dimension()` returns the reduced count. -* Conditioning convention: after assembling `V`, columns associated with derivative dofs - are rescaled by powers of `coordinate_mapping.cell_size()` (a per-vertex $h$). This - redefines the physical dofs as $h$-scaled derivatives — consistent across cells because - the scaling depends only on shared vertices/edges. Any new transformation must follow - the same convention or mass-matrix conditioning degrades - (`test/finat/test_mass_conditioning.py`). -* Verification: `test/finat/test_zany_mapping.py::check_zany_mapping` computes the exact - $M$ numerically by tabulating the FIAT element on a *physical* cell and least-squares - solving against pulled-back (and Piola-mapped, if applicable) reference tabulations, - then compares against `basis_transformation` evaluated through `gem.interpreter`. Its - assertion message pretty-prints the relative-error matrix with row/column indices — - read those indices to identify which dof couplings are wrong. - -## Style and Conventions - -When writing Python code for FIAT, maintain the ecosystem's structural and stylistic integrity: - -* Class attributes must be declared in one visible location. -* Attributes must be initialized in the `__init__` constructor or declared as a `functools.cached_property` if they are expensive to compute. -* Ad hoc lazy initialization discovering attributes via `hasattr`, `setattr`, or `getattr` scattered across methods is strictly prohibited. -* Boolean attributes must be used to record initialization intent and state instead of probing for the presence of state built by an initialization function. -* New code must include type hints on all function and method signatures. -* Public-facing APIs must include properly formatted `numpydoc`-style docstrings. diff --git a/finat/AGENTS.md b/finat/AGENTS.md new file mode 100644 index 000000000..9ed7be9d6 --- /dev/null +++ b/finat/AGENTS.md @@ -0,0 +1,101 @@ +## FInAT implementation conventions + +* `coordinate_mapping.jacobian_at(point)` returns $\partial x_{\text{phys}} / \partial + x_{\text{ref}}$ — the **inverse** of the papers' $J$ (papers map physical → reference). + So where the paper writes $J^{-T}$, the code uses the FInAT Jacobian transposed; e.g. + Hermite's vertex blocks are `J[j, k]` entries of $M$ directly. +* `basis_transformation` builds the numpy object array `V` (row = reference node, column + = physical node, entries are GEM scalars) and returns `gem.ListTensor(V.T)` = $M$. + Start from `identity(ndof)` (in `finat/physically_mapped.py`) and overwrite the + non-identity rows. +* Existing generalized helpers, all in `finat/argyris.py`: + * `_jet_transform(J, order)`: chain-rule block for a symmetric derivative jet of any + order (order 1 → Jacobian, order 2 → $\Theta$, …), handling symmetric-component + flattening. + * `_vertex_transform(V, vorder, cell, mapping)`: places jet blocks for every vertex. + * `_normal_tangential_transform(cell, J, detJ, edge)`: returns $(B_{nn}, B_{nt}, Jt)$ + for an edge, expressing $B$ entries via $G^{TT}$ Gram data so only GEM-representable + quantities appear (`detJ/beta`, `alpha/beta`); 3D variant for faces is + `morley_transform` in `finat/walkington.py` (Morley itself is automated now; this + 3D helper survives only as Walkington's dependency). + * `_edge_transform(V, vorder, eorder, cell, mapping, avg)`: the Jacobi-moment edge rows + for integral-variant Argyris/HCT, encoding the endpoint values + $P_i^{(1,1)}(\pm 1)$ and the trace-moment coupling. +* Reduced/constrained elements (`ReducedHsiehCloughTocher` in `finat/hct.py`, `Bell`): + the FIAT element is the *extended* element (12 basis functions for reduced HCT), the + FInAT element exposes only $\nu$ dofs: `V = identity(numbf, ndof)` is rectangular, + `entity_dofs()` is overridden to empty the constrained entities, and + `space_dimension()` returns the reduced count. +* Conditioning convention: after assembling `V`, columns associated with derivative dofs + are rescaled by powers of `coordinate_mapping.cell_size()` (a per-vertex $h$). This + redefines the physical dofs as $h$-scaled derivatives — consistent across cells because + the scaling depends only on shared vertices/edges. Any new transformation must follow + the same convention or mass-matrix conditioning degrades + (`test/finat/test_mass_conditioning.py`). +* Verification: `test/finat/test_zany_mapping.py::check_zany_mapping` computes the exact + $M$ numerically by tabulating the FIAT element on a *physical* cell and least-squares + solving against pulled-back (and Piola-mapped, if applicable) reference tabulations, + then compares against `basis_transformation` evaluated through `gem.interpreter`. Its + assertion message pretty-prints the relative-error matrix with row/column indices — + read those indices to identify which dof couplings are wrong. + +## Mathematical structures to recognize in FIAT/FInAT + +* **A degree of freedom is fully described by five numbers, not by its FIAT class.** Every + functional FIAT builds from `pt_dict`/`deriv_dict` reduces to (points, weights, derivative + order $m$, a direction tensor of rank $m$, a value rank for vector/tensor-valued dofs). + `IntegralMomentOfNormalDerivative`, `PointNormalDerivative`, `TensorBidirectionalIntegralMoment`, + etc. are just different ways of *constructing* the same five numbers. Recognizing this + collapses "N functional types to support" into "one shape to recover numerically" + (`finat.PhysicallyMappedFunctional.from_fiat`, `finat/functional.py`). +* **Pullback is always "contract each tensor slot of the direction with a fixed matrix."** + Order-0 (values) are invariant (zero slots to contract). Order-$m$ derivatives contract $m$ + slots with the Jacobian $J$ (the chain rule). Rank-$r$ Piola values contract $r$ slots with + the cofactor matrix $K = \operatorname{adj}(J)^T$. This is *the same operation* with a + different matrix, which is why the scalar and Piola code in `finat/zany.py` are mirror images + of each other (`_scalar_point_rows` / `_piola_point_rows`, `_scalar_facet_rows` / + `_piola_facet_rows`) rather than unrelated implementations. +* **Frame decomposition is the one computational primitive underlying every non-affine + element.** Facet dofs (Morley/Argyris/Bell normal derivatives; MTW/JM/GN normal-tangential + moments) and vertex-jet completions (Hermite/Argyris/Bell gradients and Hessians) are all + solved the *same* way: split a direction/profile into an invariant part and a part that needs + completing, express the pulled-back quantity in the frame built from the mapped generators of + that split (`FacetFrame`, or the direction-basis inverse in `_scalar_point_rows`), and solve + symbolically via `adjugate`/`determinant`. Once this pattern is visible, "add a new element" + stops being "derive new math" and becomes "which invariant subspace, which frame." +* **Constrained/extended elements are the same construction as a restriction.** Bell and + Guzman-Neilan are both "take the extended FIAT element with its constraint functionals as + extra dofs, transform the whole thing, then keep only the first $\nu$ columns." This is + Kirby (2017) §5's extended-element proposition, and in code it is nothing more than + `space_dimension()` returning a smaller count than the FIAT element's — no special-casing + needed in the transformation loop itself. + +### Confusing mathematical ideas, clarified + +* **The Jacobian direction is flipped between the papers and the code.** The papers define + $F$ from physical to reference space, so their $J$ is FInAT's Jacobian *inverse*. + `coordinate_mapping.jacobian_at(point)` returns $\partial x_{\text{phys}}/\partial + x_{\text{ref}}$ (see "FInAT implementation conventions" below); a paper's $J^{-T}$ is FInAT's + plain Jacobian, transposed. This is a constant source of "why does my formula look + transposed" confusion — check this convention flip before suspecting a sign or index bug. +* **Components against a reciprocal/dual basis transform contragrediently — this is the single + subtlety that broke 3D and is not in any of the papers.** FIAT builds the tangential + component of 3D Piola-mapped dofs (MTW) using `cross(n, t_k)`, the *reciprocal* partner of + the tangent frame, not the tangent frame itself. A general fact from differential geometry: + if a frame transforms by a matrix $A$, components expressed against its *reciprocal* frame + transform by $A^{-T}$ (up to a determinant factor), not by $A$. Kirby (2017)/Aznaran-Kirby- + Farrell (2022) only work out the 2D case, where the tangent "plane" is 1-dimensional and this + correction $S^{-1}$ collapses to $1$ — silently hiding the effect. **Whenever a FIAT dual set + builds a direction via a cross product against the normal (a common way to get "the other + in-plane directions" in 3D), check whether its transformation law is contragredient before + assuming it matches the direct frame.** +* **Numerically recovered invariants (SVD, pinv) are only defined up to a group action, and + formulas built from them must be invariant under that action.** `from_fiat`'s SVD recovers a + direction/weight pair $(\hat n, w)$ up to a joint sign flip ($\hat n \to -\hat n$, $w \to + -w$ leaves the functional unchanged). A formula like $r_k = a x_k + (1-c)\beta_k$ that + implicitly assumes a particular sign will be wrong on exactly the subset of entities where + SVD happened to pick the other sign — a bug that looks like it "mostly works" and is + otherwise very hard to localize. The fix, $r_k = x_k - c\beta_k$, is invariant under the + joint flip. **Always test the full topology of a non-degenerate, non-symmetric physical + cell** (see `test/finat/conftest.py::MyMapping`'s deliberately irregular vertex coordinates) — + a symmetric test cell can accidentally hide a sign bug that only shows up on a generic mesh. diff --git a/gem/AGENTS.md b/gem/AGENTS.md new file mode 100644 index 000000000..7c52b4374 --- /dev/null +++ b/gem/AGENTS.md @@ -0,0 +1,29 @@ +### Confusing GEM patterns, clarified + +* **Use operator overloading, not manual node construction.** `gem.Node` overloads + `+ - * / ** @` (via `as_gem`/`componentwise`) to work transparently across GEM nodes, + `gem.Literal`, and plain Python/numpy numbers, with automatic `Zero` folding. Write + `havg**(-m)`, never `gem.Power(havg, gem.Literal(-m))`: the operator form also works when + `havg` is a raw float (as in the test harness's `MyMapping.cell_size()`) whereas a manual + `gem.Power` call assumes GEM operands and will not always coerce correctly. +* **Never call `numpy.linalg.inv`/`solve` on a GEM-valued matrix.** LAPACK cannot see inside + GEM expressions. Symbolic linear solves use `adjugate(A) / determinant(A)` + (`finat/physically_mapped.py`) — the symbolic Cramer's-rule equivalent — e.g. in + `FacetFrame.decompose` and `_piola_facet_rows`. Numeric `numpy.linalg` calls are only valid + when every entry is a plain number: reference-cell-only quantities + (`FacetFrame.reference_coefficients`) or purely numeric direction-basis inversions + (`_scalar_point_rows`, `_piola_point_rows`). Check which regime an array is in before + reaching for a numpy linear-algebra routine. +* **Build sparse GEM-valued arrays with `numpy.full(shape, gem.Zero(), dtype=object)`**, never + `numpy.zeros(shape)` — plain `0` is not interchangeable with `gem.Zero()` when the array will + later be combined with GEM nodes via `+`. +* **"Start from identity, mutate only the rows that need work" is not an optimization, it is + the mathematical content.** `V = identity(ndof)` (`finat/physically_mapped.py`) encodes + "these dofs are push-forward invariant" directly; the invariant-dof detection + (`_invariant_dofs`) simply chooses *not* to touch those rows, rather than writing `1`s + explicitly. Treat the untouched identity rows as the base case of the assembly recursion. +* **Row/column convention, and where the one transpose happens.** Throughout assembly, row + index = reference node, column index = physical node — i.e. the code builds $V$, never $M$ + directly. `ListTensor(V.T)` at the very end is the single place Kirby (2017) Theorem 3.1's + $M = V^T$ gets applied. If something looks transposed, check this convention before + suspecting a sign error. diff --git a/zany_claude.md b/zany_claude.md new file mode 100644 index 000000000..ec1dc4617 --- /dev/null +++ b/zany_claude.md @@ -0,0 +1,231 @@ +## Transformation Theory (Kirby 2017; Brubeck & Kirby 2025) + +The mathematical framework for mapping non-affinely-equivalent elements. Notation: the +geometric map goes **from physical to reference**, $F: K \to \hat{K}$; pullback +$F^*(\hat{f}) = \hat{f}\circ F$; push-forward of a node $F_*(n) = n \circ F^*$. + +* Goal: the matrix $M$ with $\Psi = M\, F^*(\hat\Psi)$, expressing physical nodal basis + functions as combinations of pulled-back reference basis functions. +* Duality is the whole game: with $B_{ij} = F_*(n_i)(\hat\psi_j) = n_i(F^*(\hat\psi_j))$, + the Kronecker property gives $M = B^{-T}$ and $V = B^{-1}$, where $V$ relates nodes: + $\hat{N} = V\, F_*(N)$ (restricted to $P$). Hence **$M = V^T$**, and $V$ is what one + actually constructs, because push-forwards of nodes are computable by the chain rule. +* Affine equivalence (Lagrange): $F_*(N) = \hat{N}$, so $M = I$. +* Affine-interpolation equivalence (Hermite): spans of nodes at each point are preserved, + so $V$ is block diagonal, one small block per point-node group (e.g. $J^{-T}$ blocks for + vertex gradients — in the *paper's* $J$; see the FInAT convention below). +* Neither (Morley, Argyris, HCT): edge normal derivatives alone do not push forward into + the span of reference nodes. Fix by a **compatible nodal completion** $N^c \supset N$, + $\hat{N}^c \supset \hat{N}$ with $\mathrm{span}(F_*(N^c)) = \mathrm{span}(\hat{N}^c)$ + (add the tangential-derivative partners so each edge carries a full gradient). Then + $$V = E\, V^c\, D,$$ + with the three factors having distinct mathematical roles: + * $D \in \mathbb{R}^{\mu\times\nu}$: expresses the completed physical nodes in terms of + the actual physical nodes, *restricted to* $P$ ($\pi N^c = D\, \pi N$). Rows for nodes + already in $N$ are Boolean. Rows for completion nodes come from a univariate exactness + argument on the edge: a tangential-derivative quantity of a polynomial of known edge + degree is an exact linear combination of the endpoint values/derivatives (FTC for HCT's + $\mu^t_e$; the quintic endpoint rule for Argyris/Bell midpoint tangential derivatives). + This is the only factor that uses the polynomial degree of the space. + * $V^c \in \mathbb{R}^{\mu\times\mu}$: block diagonal, pure chain rule, + $\hat{N}^c = V^c F_*(N^c)$. Vertex value $\to 1$; vertex gradient $\to$ Jacobian block; + vertex Hessian $\to$ symmetric-square block $\Theta$; edge normal/tangential pair $\to$ + the $2\times2$ block $B_i = \hat{G}_i J^{-T} G_i^T$ (paper's $J$), where + $G = [\mathbf{n}\; \mathbf{t}]^T$. Only the first row of $B_i$ is needed + ($B_{nn}$, $B_{nt}$) since tangential rows are discarded by $E$. + * $E \in \mathbb{R}^{\nu\times\mu}$: Boolean extraction of $\hat{N}$ from $\hat{N}^c$. +* Constrained spaces, $F^*(\hat{P}) \neq P$ (Bell, reduced HCT): the space is + $P = \bigcap_i \mathrm{null}(\lambda_i)$ inside a larger $\tilde{P}$ that *is* preserved + (e.g. reduced HCT = HCT functions whose edge normal derivative is linear; + $\lambda_i$ = moment of the normal derivative against the quadratic Legendre polynomial + on edge $i$). Build the **extended element** $(K, \tilde{P}, [N; L])$ with the + constraints $\lambda_i$ appended as extra nodes; it is a valid finite element, and its + first $\nu$ nodal basis functions are exactly the nodal basis of the constrained + element. Transform the extended element with the $E V^c D$ machinery (completing each + $\lambda_i$ with its tangential partner $\lambda_i'$, again eliminated through an edge + exactness rule), then discard the constraint rows. +* Brubeck & Kirby 2025 refinements: + * Define *reference* edge nodes as integral **averages** ($1/|\hat{e}|$ scaling) while + physical nodes are plain moments: the edge-length Jacobian of the line integral is then + absorbed and no reference-edge-length bookkeeping or orientation logic is needed. + * High-order HCT/Argyris: edge normal-derivative moments are taken against Jacobi + polynomials $P_i^{(1,1)}$ (weights $(2,2)$ for Argyris, matching the vertex jet order), + so the edge-based functions are hierarchical. The tangential completion + $\mu^t_{e,i}$ integrates by parts against the *trace* moments (defined against + $\frac{d}{ds}P_i^{(1,1)}$): $\mu^t_{e,i}(f) = -\mu_{e,i}(f) + P_i^{(1,1)}(1)\, + \delta_{v_b}(f) - P_i^{(1,1)}(-1)\,\delta_{v_a}(f)$. This couples normal moments to + trace moments, not just vertex dofs. + +### Active work: automating the transformation (see `plan_zany_auto.md`) + +Goal: replace the hand-coded `basis_transformation` methods with a helper that derives +$V = E V^c D$ directly from a FIAT element's dual basis. The implementation must mirror +the theory factor by factor, not merely reproduce matrix entries: + +**Status (2026-07-13).** The symbolic dof lives in `finat/functional.py` as +`finat.PhysicallyMappedFunctional`. `finat/physically_mapped.py` stays fully generic: +`PhysicallyMappedElement` is unchanged from before this project, still an abstract +mixin with no knowledge of the zany theory, used as-is by hand-coded elements (AW, +HCT, PowellSabin, Walkington, ...). All the automation lives in `finat/zany.py`, as a +template method on `ZanyPhysicallyMappedElement(PhysicallyMappedElement)`: the +entity-by-entity assembly loop is implemented once, in its concrete +`basis_transformation`, calling four hooks (`_check_mapping`, `_invariant_dofs`, +`_facet_dof_rows`, `_point_dof_rows`) that carry ALL mapping-specific knowledge — the +loop itself contains no `if piola` anywhere. Two mixins implement those hooks, +`ScalarPhysicallyMappedElement` (affine pullback: Morley, Hermite, Argyris, Bell) and +`PiolaPhysicallyMappedElement` ((double) contravariant Piola: MTW, Johnson-Mercier, +Guzman-Neilan), plus the pure math functions they call (`FacetFrame`, +`_scalar_facet_rows`, `_scalar_point_rows`, `_piola_facet_rows`, `_piola_point_rows`) — +these take plain arrays/GEM expressions, no `self`, so the mathematics stays readable +independent of the class plumbing. Concrete elements (`finat.Morley`, etc.) are now +just a citation plus a FIAT constructor call: mixing in the right base class is enough, +`basis_transformation` is inherited (MRO example: `Morley -> ScalarPhysicallyMappedElement +-> ZanyPhysicallyMappedElement -> PhysicallyMappedElement -> ...`). `ndof` truncation is +no longer a parameter; the loop always slices by `self.space_dimension()`, which +constrained elements (Bell, GN) already override. Tests: +`test/finat/test_zany_automation.py`; `check_zany_mapping` lives in the finat conftest +and is provided to test modules as a pytest fixture (pytest runs with +`--import-mode=importlib`, so test modules cannot import from each other or from +conftest). + +**Framework design.** A dof is a symbolic `finat.PhysicallyMappedFunctional`: +$\ell(f) = \sum_q w_q \langle D, \nabla^m f(x_q)\rangle$ with numeric points/weights +and a direction tensor $D$ that is numeric on the reference cell and GEM otherwise. +There is *no dispatch over FIAT functional types*: `PhysicallyMappedFunctional.from_fiat` +reads only `pt_dict`/`deriv_dict` and recovers the order and common direction numerically +(rank-one SVD of the derivative weights). Operations: covariant `pullback(J)` +(contract direction slots with $J$), `with_direction`, and numeric `evaluate` against +a nodal basis (the generalized Vandermonde realizing the $D$ factor of $V = E V^c D$). +The row of $V$ for a reference node $\hat\ell$ with direction $\hat d = a\hat n + +\sum_k \beta_k \hat t_k$ (numeric split) is assembled generically: + +* $a = 0$ (value dofs, or tangential directions): push-forward invariant, identity row. +* otherwise solve $J\hat d = x_0 C + \sum_k x_k J\hat t_k$ symbolically + (`FacetFrame.decompose`, adjugate/determinant of the polynomial frame matrix), where + $C$ = generalized cross product of the mapped tangents. The physical node has + direction $aN + \sum\beta_k J\hat t_k$ with $N = \kappa C/\|C\|$, so its coefficient + is $c = x_0\|C\|/(\kappa a)$ and the tangential remainders are $r_k = x_k - c\beta_k$ + (careful: *not* $a x_k$ — the SVD direction may be $-\hat n$, and all formulas must be + invariant under that sign flip; a sign bug here flips exactly the edges where the SVD + chose the opposite orientation). +* the remainders multiply completion functionals along *mapped reference tangents*, + which coincide with reference functionals; `PhysicallyMappedFunctional.evaluate` gives their numeric + expansion in the element's own nodes, and the row combination recurses through the + already-assembled rows of $V$ (entities processed in increasing dimension), which + will later let completions couple to vertex jets (Argyris/HCT) for free. + +Derivative nodes *away from facets* (`_scalar_point_rows`, covering Hermite vertex +gradients) have no geometric frame: FIAT keeps Cartesian directions on the physical +cell, so the group of derivative nodes on the entity acts as its own completion — this +is precisely affine-interpolation equivalence. The pulled-back direction $J\hat d_i$ +is expanded in the group's own (numeric) direction basis, with weight-ratio factors +making the expansion invariant under the SVD scale/sign ambiguity of each node's +recovered $(w, D)$ factorization. The group must span the derivative jet, share its +points, and have pairwise-parallel weights; otherwise `NotImplementedError`. + +Key facts the framework rests on: + +* **FIAT normals are "UFC consistent":** computed from the tangents by the same formula + on reference and physical cells (`UFCTriangle.compute_normal` rotates the scaled edge + tangent; `UFCTetrahedron.compute_normal` is $-2\times$ the unit cross product of the + scaled face tangents), with cell-independent magnitude. Hence $N = \kappa C/\|C\|$ + with $\kappa$ recoverable from reference data, and no orientation logic is needed + (assumes $\det J > 0$). For the record, the fully simplified closed forms the solve + reproduces are $a = \det J\sqrt{\det\hat G/\det G}$ and $b = G^{-1}T^TJ\hat n$ with + Gram matrices of the (mapped) tangents. +* **Integral averages are push-forward invariant.** FIAT's Morley dofs are averages + (`FacetQuadratureRule(..., avg=True)`), so physical nodes share the reference weights + and no `physical_edge_lengths` appear. The framework *assumes* measure-intrinsic + moments (the Brubeck & Kirby 2025 reference-node convention) — documented in + `finat/functional.py`. +* **The conditioning $h$-scaling generalizes via `max_deriv_order`.** Column $j$ is + scaled by $h_E^{-m}$ where $m$ is the derivative order of node $j$ and $h_E$ averages + `cell_size()` over the vertices of its entity; reproduces every per-element + hand-written scaling. `cell_size()` returns raw numpy values in the test mappings but + GEM in Firedrake, so use operator arithmetic (`havg**(-m)`), not GEM constructors + (GEM overloads `+ - * / ** @` with `Zero`/constant folding; keep numpy object arrays + on the left when scaling by a GEM scalar). + +Extensions beyond first order and Morley/Hermite: + +* `PhysicallyMappedFunctional` directions live in derivative multi-index space (`multiindices`, axis + order for $m=1$); `pullback` distributes them over a symmetric tensor (dividing by + multiplicities), contracts every slot with $J$ (`numpy.tensordot` on object arrays), + and collapses back. Point-jet groups are split per order; each order solves in its + own multi-index direction basis (Argyris/Bell vertex jets: gradient + Hessian). +* Facet completions of Argyris edge moments couple to vertex jets and same-edge trace + moments; the existing row recursion handles both with no new code (trace moments are + order-0 and thus invariant; FIAT builds all these moments with + `FacetQuadratureRule(avg=True)`, i.e. measure-intrinsic, as the framework assumes). +* `ScalarPhysicallyMappedElement.avg = False` (an instance attribute Argyris sets from + its constructor kwarg) reproduces the legacy FInAT convention where physical facet + moments are plain integrals: their columns are divided by the physical facet measure + $\|C\| |\hat e| / \|\hat C\|$ (`FacetFrame.measure`). Single-point facet dofs + (Argyris "point" variant) are unaffected. +* Bell is the extended-element pattern: FIAT.Bell is the 21-node quintic element with + the constraint functionals as extra edge nodes; overriding `space_dimension()` to 18 + drops the constraint *columns* of $V$ at the end of the template method (their rows + still contribute the $D$-matrix entries through the completion recursion), and the + FInAT element overrides `entity_dofs`. +* Known convention change: the generic $h^{-m}$ conditioning scaling now also applies + to integral-variant Argyris edge moments, which the hand-written code left unscaled + (Morley scaled them; the legacy convention was inconsistent). Invisible when + `cell_size == 1`; flag in PR review. + +**Piola-mapped elements** (Aznaran, Kirby & Farrell 2022). `PhysicallyMappedFunctional` carries a +value rank: component weight profiles (nq x sd^rank) parsed from `pt_dict` component +tuples. Under contravariant Piola the roles of the scalar case are mirrored: the +*scaled* facet normal is the cofactor image $K\hat n_s$, $K = \mathrm{adj}(J)^T$ +(exactly the physical `compute_scaled_normal`, cross product of mapped tangents), so +pure normal moments are invariant, while scaled tangents map by $J$. `_piola_facet_rows` +works with per-point *frame-coordinate profiles* (handles 3D MTW's point-varying +RT-mapped tangential directions): the pulled-back profile is contracted per value slot +with the mixing matrix $Y$; tangential profiles are matched within the facet group by a +numeric pseudo-inverse; the residual normal profile is eliminated by per-point normal +moments through the Vandermonde recursion (this is where e.g. tangential-to-normal +couplings emerge). Key subtlety (in any dimension > 2): FIAT builds tangential value +components on the **reciprocal basis** (`cross(n, t_k)`), which transforms in-plane +contravariantly: absorb $S^{-1} = (\det\hat G_t/\det G_t)\hat G_t^{-1} G_t$ +(tangent Gram change) into $Y$'s tangential rows; in 2D $S = 1$, which hides the effect. +Interior value moments are Piola-invariant by construction (scaled-normal components for +JM; covariant Nedelec test fields for MTW order 2 cancel the contravariant trial +exactly). Double contravariant (tensor) elements use the same code: one contraction per +value slot. MTW (2D/3D) and Johnson-Mercier (2D/3D) are reimplemented this way, matching +the deleted hand code to machine precision; RaviartThomas-type elements come out as pure +identity (Piola-equivalent) automatically. + +GuzmanNeilanFirstKindH1 (orders 0-2, 2D/3D) is also automatic: vertex/edge point +values are value point-groups mapping by $K$ (`_piola_point_rows`, the mirror of +`_scalar_point_rows`), and the trailing tangential facet constraints are dropped since +`PiolaBubbleElement.space_dimension()` already returns the reduced count (the Bell +pattern, inherited rather than passed as a parameter); `PiolaBubbleElement.__init__` +still provides the reduced `entity_dofs` bookkeeping. `GuzmanNeilanFirstKindH1` is +`class GuzmanNeilanFirstKindH1(PiolaPhysicallyMappedElement, PiolaBubbleElement)`: MRO +puts the automatic `basis_transformation` first, while `PiolaBubbleElement.__init__` +(reached via `super()`) still sets up `self._element` and the reduced dof bookkeeping. Its hand-derived vertex-facet coupling correction ("fix +discrepancy" in `finat/piola_mapped.py`) emerges automatically from the Vandermonde +residual elimination, since the per-point normal moments evaluate against vertex basis +functions of the extended element. + +Next steps: remaining PiolaBubbleElement users (GN second kind, H1div: interior +derivative moments need the divergence detJ rule), ArnoldWinther (vertex tensor values ++ higher facet moments), the extended-element path for reduced HCT (macro polynomial +spaces); covariant elements. + +The implementation mirrors the theory factor by factor: + +1. **Sparsity from topology**: each row of $V$ is a reference node; its nonzero columns + are the physical nodes on the same entity (via $V^c$) plus the nodes on adjacent + entities pulled in by $D$ (e.g. edge rows couple to the edge's vertex dofs, and for + hierarchical elements to the same edge's trace moments). Derive this from + `entity_dofs()` and functional types (`FIAT/functional.py`), never hard-code indices. +2. **Completion from functional type**: a normal-derivative node's completion partner is + the corresponding tangential-derivative node; the completion is a per-entity statement, + determined by which components of the derivative jet the dual basis lacks. +3. **$D$ from unisolvence, not from closed-form rules**: the univariate exactness rules + (FTC, quintic endpoint rule, integration by parts against trace moments) are all + instances of one computation — express a completion functional applied to the + polynomial space in the basis of the element's own nodes, i.e. solve with the + generalized Vandermonde matrix. This is where GEM-based dual evaluation comes in. +4. Constrained spaces (reduced HCT, Bell) reuse the same machinery on the extended + element; the FIAT element must expose the constraint functionals as extra dofs. From b2f210554a999279a58efd83c2b4cfcc26dd0c26 Mon Sep 17 00:00:00 2001 From: Pablo Brubeck Date: Wed, 15 Jul 2026 11:21:39 +0100 Subject: [PATCH 18/34] AGENTS.md --- AGENTS.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index aa3b37756..5d726ceb6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,10 +29,14 @@ FIAT operates within Firedrake's automated system for solving partial differenti Despite being called "fiat", this repository (Python package `firedrake_fiat`) contains **three** packages plus their tests, all in one tree: * `FIAT/` — reference-element definitions: cells, polynomial sets, dual bases (`FIAT/functional.py` holds the taxonomy of degree-of-freedom functionals), and element families. -* `finat/` — symbolic layer on top of FIAT. Physically mapped elements (`finat/physically_mapped.py`, `finat/argyris.py`, `finat/morley.py`, `finat/hct.py`, `finat/piola_mapped.py`, …) construct GEM expressions for basis transformations. -* `gem/` — the tensor-algebra intermediate language used to express transformations symbolically (`gem/gem.py` for nodes, `gem/interpreter.py` to evaluate expressions numerically in tests). +* `finat/` — symbolic layer on top of FIAT. Physically mapped elements (`finat/physically_mapped.py`, `finat/argyris.py`, `finat/morley.py`, `finat/hct.py`, `finat/piola_mapped.py`, …) construct GEM expressions for basis transformations. See `finat/AGENTS.md` for package-specific implementation conventions. +* `gem/` — the tensor-algebra intermediate language used to express transformations symbolically (`gem/gem.py` for nodes, `gem/interpreter.py` to evaluate expressions numerically in tests). See `gem/AGENTS.md` for package-specific implementation conventions. * `test/` — note the singular name: tests live at `test/finat/test_zany_mapping.py`, `test/FIAT/...`, `test/gem/...` (not `tests/`). +Read the subdirectory `AGENTS.md` for any package you are about to edit — they hold +implementation-level conventions (GEM operator overloading rules, FInAT Jacobian sign +conventions, etc.) that this top-level file does not repeat. + ## Environment and Setup Bugs can exist within Firedrake or any of its component packages, explicitly including FIAT. To effectively develop and debug FIAT: From 8b068b8cc5dc993936ac5070054e2ea357094621 Mon Sep 17 00:00:00 2001 From: Pablo Brubeck Date: Wed, 15 Jul 2026 11:34:11 +0100 Subject: [PATCH 19/34] in-place assembly --- finat/zany.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/finat/zany.py b/finat/zany.py index 31838ca2c..12fbf871d 100644 --- a/finat/zany.py +++ b/finat/zany.py @@ -353,8 +353,8 @@ def _scalar_facet_rows(V: numpy.ndarray, group: dict, fiat_element: FiniteElemen if not avg and len(ell.points) > 1: # the physical moment is a plain integral, not an average c = c / frame.measure - row = numpy.full(V.shape[1], Zero(), dtype=object) - row[i] = c + + V[i, i] = c for k, that in enumerate(frame.tangents): r = x[k + 1] - c * beta[k] coefficients = ell.with_direction(that).evaluate(fiat_element) @@ -364,8 +364,7 @@ def _scalar_facet_rows(V: numpy.ndarray, group: dict, fiat_element: FiniteElemen raise NotImplementedError( f"Completion of node {i} couples to node {j}, " "which has not been transformed yet.") - row = row + V[j, :] * (r * coefficients[j]) - V[i, :] = row + V[i] += V[j] * (r * coefficients[j]) processed.add(i) From 9df42042237b603232840b8d7e909227809b5b9e Mon Sep 17 00:00:00 2001 From: Pablo Brubeck Date: Wed, 15 Jul 2026 13:24:12 +0100 Subject: [PATCH 20/34] cleanup --- finat/zany.py | 590 ++++++++++++++++---------------- test/finat/conftest.py | 105 +----- test/finat/test_zany_mapping.py | 121 ++++++- 3 files changed, 398 insertions(+), 418 deletions(-) diff --git a/finat/zany.py b/finat/zany.py index 12fbf871d..08f495b66 100644 --- a/finat/zany.py +++ b/finat/zany.py @@ -36,6 +36,7 @@ (respectively normal) completion realizes :math:`D`. """ +from abc import abstractmethod from functools import reduce from operator import add @@ -44,7 +45,7 @@ from FIAT.finite_element import FiniteElement from gem import Literal, ListTensor, Node, Power, Zero from finat.functional import PhysicallyMappedFunctional -from finat.physically_mapped import PhysicallyMappedElement, adjugate, determinant, identity +from finat.physically_mapped import PhysicallyMappedElement, adjugate, determinant, identity, inverse def generalized_cross(tangents) -> numpy.ndarray: @@ -153,10 +154,10 @@ class ZanyPhysicallyMappedElement(PhysicallyMappedElement): the already-assembled rows of lower-dimensional entities. On each entity, nodes that are already push-forward invariant - (:meth:`_invariant_dofs`) contribute an identity row for free, + (:meth:`invariant_dofs`) contribute an identity row for free, since :math:`V` starts out as the identity. The rest are assembled - by :meth:`_facet_dof_rows` (on a codimension-1 entity) or - :meth:`_point_dof_rows` (elsewhere); these hooks, together with + by :meth:`facet_dof_rows` (on a codimension-1 entity) or + :meth:`point_dof_rows` (elsewhere); these hooks, together with :meth:`_check_mapping`, encode the mapping-specific (affine or Piola) part of the theory, and :meth:`basis_transformation` itself contains no knowledge of which mapping is in play. @@ -184,15 +185,15 @@ def basis_transformation(self, coordinate_mapping) -> ListTensor: for entity in sorted(entity_ids[dim]): group = {i: PhysicallyMappedFunctional.from_fiat(nodes[i]) for i in entity_ids[dim][entity]} - invariant = self._invariant_dofs(group, dim, sd) + invariant = self.invariant_dofs(group, dim, sd) processed.update(invariant) group = {i: ell for i, ell in group.items() if i not in invariant} if not group: continue if dim == sd - 1: - self._facet_dof_rows(V, group, fiat_element, entity, J, processed) + self.facet_dof_rows(V, group, fiat_element, entity, J, processed, tol=self.tol) else: - self._point_dof_rows(V, group, fiat_element, J, processed) + self.point_dof_rows(V, group, fiat_element, entity, J, processed, tol=self.tol) _rescale_derivative_dofs(V, fiat_element, coordinate_mapping) ndof = self.space_dimension() @@ -204,10 +205,10 @@ def _check_mapping(self, fiat_element): :arg fiat_element: The FIAT element defined on the reference cell. :raises NotImplementedError: If the pullback is not supported. """ - raise NotImplementedError( - f"{type(self).__name__} does not implement automatic basis transformation.") + pass - def _invariant_dofs(self, group, dim, sd): + @abstractmethod + def invariant_dofs(self, group, dim, sd): """Select the nodes of an entity that are already push-forward invariant. :arg group: Dict mapping node index to :class:`PhysicallyMappedFunctional` @@ -217,10 +218,10 @@ def _invariant_dofs(self, group, dim, sd): :returns: The subset of ``group`` keys whose row of :math:`V` is the identity row. """ - raise NotImplementedError( - f"{type(self).__name__} does not implement automatic basis transformation.") + pass - def _facet_dof_rows(self, V, group, fiat_element, entity, J, processed): + @abstractmethod + def facet_dof_rows(self, V, group, fiat_element, entity, J, processed, tol): """Assemble the rows of V for the non-invariant nodes on a facet. :arg V: Object array being assembled; rows are set in place. @@ -231,11 +232,12 @@ def _facet_dof_rows(self, V, group, fiat_element, entity, J, processed): :arg J: GEM expression for the cell Jacobian. :arg processed: Indices of the already assembled rows of ``V``; updated in place. + :arg tol: Tolerance for detecting zeros in the numeric coefficients. """ - raise NotImplementedError( - f"{type(self).__name__} does not implement automatic basis transformation.") + pass - def _point_dof_rows(self, V, group, fiat_element, J, processed): + @abstractmethod + def point_dof_rows(self, V, group, fiat_element, entity, J, processed, tol): """Assemble the rows of V for the non-invariant nodes away from a facet. :arg V: Object array being assembled; rows are set in place. @@ -245,9 +247,9 @@ def _point_dof_rows(self, V, group, fiat_element, J, processed): :arg J: GEM expression for the cell Jacobian. :arg processed: Indices of the already assembled rows of ``V``; updated in place. + :arg tol: Tolerance for detecting zeros in the numeric coefficients. """ - raise NotImplementedError( - f"{type(self).__name__} does not implement automatic basis transformation.") + pass def _rescale_derivative_dofs(V, fiat_element, coordinate_mapping): @@ -302,111 +304,101 @@ def _check_mapping(self, fiat_element): raise NotImplementedError( f"{type(self).__name__} expects an affine pullback, not {mappings}.") - def _invariant_dofs(self, group, dim, sd): + def invariant_dofs(self, group, dim, sd): # Point values pull back exactly; only derivative nodes need work. return {i for i, ell in group.items() if ell.order == 0} - def _facet_dof_rows(self, V, group, fiat_element, entity, J, processed): - _scalar_facet_rows(V, group, fiat_element, entity, J, processed, - self.tol, self.avg) - - def _point_dof_rows(self, V, group, fiat_element, J, processed): - _scalar_point_rows(V, group, J, processed, self.tol) - - -def _scalar_facet_rows(V: numpy.ndarray, group: dict, fiat_element: FiniteElement, - entity: int, J: Node, processed: set, tol: float, - avg: bool = True) -> None: - r"""Assemble the rows of V for derivative nodes on a facet. - - Physical facet nodes take their normal component along the physical - facet normal and their tangential components along the mapped - reference tangents. The pulled-back reference node is expanded in - this frame, and the tangential remainders, being derivatives along - mapped reference tangents, coincide with reference functionals that - are eliminated numerically through already assembled rows of V. - - :arg V: Object array being assembled. - :arg group: Mapping from node index to symbolic PhysicallyMappedFunctional - for the derivative nodes on this facet. - :arg fiat_element: The FIAT element. - :arg entity: The facet number. - :arg J: GEM expression for the cell Jacobian. - :arg processed: Indices of the already assembled rows; updated in place. - :arg tol: Tolerance for detecting zeros in the numeric coefficients. - :arg avg: If False, physical facet moments are plain integrals rather - than integral averages, and their columns are rescaled by the - physical facet measure. - """ - frame = FacetFrame(fiat_element, entity, J) - for i, ell in group.items(): - # Split the direction into normal and tangential parts - a, *beta = frame.reference_coefficients(ell.direction) - if abs(a) < tol: - # Mapped tangential derivatives are invariant + def facet_dof_rows(self, V: numpy.ndarray, group: dict, fiat_element: FiniteElement, + entity: int, J: Node, processed: set, tol: float) -> None: + r"""Assemble the rows of V for derivative nodes on a facet. + + Physical facet nodes take their normal component along the physical + facet normal and their tangential components along the mapped + reference tangents. The pulled-back reference node is expanded in + this frame, and the tangential remainders, being derivatives along + mapped reference tangents, coincide with reference functionals that + are eliminated numerically through already assembled rows of V. + + :arg V: Object array being assembled. + :arg group: Mapping from node index to symbolic PhysicallyMappedFunctional + for the derivative nodes on this facet. + :arg fiat_element: The FIAT element. + :arg entity: The facet number. + :arg J: GEM expression for the cell Jacobian. + :arg processed: Indices of the already assembled rows; updated in place. + :arg tol: Tolerance for detecting zeros in the numeric coefficients. + :arg avg: If False, physical facet moments are plain integrals rather + than integral averages, and their columns are rescaled by the + physical facet measure. + """ + frame = FacetFrame(fiat_element, entity, J) + for i, ell in group.items(): + # Split the direction into normal and tangential parts + a, *beta = frame.reference_coefficients(ell.direction) + if abs(a) < tol: + # Mapped tangential derivatives are invariant + processed.add(i) + continue + + # Expand the pulled-back node in the physical frame + x = frame.decompose(ell.pullback(J).direction) + c = x[0] * frame.normal_scale / a + if not self.avg and len(ell.points) > 1: + # the physical moment is a plain integral, not an average + c = c / frame.measure + + V[i, i] = c + for k, that in enumerate(frame.tangents): + r = x[k + 1] - c * beta[k] + coefficients = ell.with_direction(that).evaluate(fiat_element) + coefficients[abs(coefficients) < tol] = 0 + for j in numpy.flatnonzero(coefficients): + if j not in processed: + raise NotImplementedError( + f"Completion of node {i} couples to node {j}, " + "which has not been transformed yet.") + V[i] += V[j] * (r * coefficients[j]) processed.add(i) - continue - - # Expand the pulled-back node in the physical frame - x = frame.decompose(ell.pullback(J).direction) - c = x[0] * frame.normal_scale / a - if not avg and len(ell.points) > 1: - # the physical moment is a plain integral, not an average - c = c / frame.measure - - V[i, i] = c - for k, that in enumerate(frame.tangents): - r = x[k + 1] - c * beta[k] - coefficients = ell.with_direction(that).evaluate(fiat_element) - coefficients[abs(coefficients) < tol] = 0 - for j in numpy.flatnonzero(coefficients): - if j not in processed: - raise NotImplementedError( - f"Completion of node {i} couples to node {j}, " - "which has not been transformed yet.") - V[i] += V[j] * (r * coefficients[j]) - processed.add(i) - - -def _scalar_point_rows(V: numpy.ndarray, group: dict, J: Node, - processed: set, tol: float) -> None: - r"""Assemble the rows of V for derivative nodes away from facets. - Away from facets there is no geometric frame, and physical nodes - keep the reference (Cartesian) directions, so the group must span - all directions and acts as its own completion: this is the - affine-interpolation equivalent case, and each pulled-back node is - expanded within the group. + def point_dof_rows(self, V: numpy.ndarray, group: dict, fiat_element: FiniteElement, + entity: int, J: Node, processed: set, tol: float) -> None: + r"""Assemble the rows of V for derivative nodes away from facets. - :arg V: Object array being assembled. - :arg group: Mapping from node index to symbolic PhysicallyMappedFunctional - for the derivative nodes on this entity. - :arg J: GEM expression for the cell Jacobian. - :arg processed: Indices of the already assembled rows; updated in place. - :arg tol: Tolerance for detecting zeros in the numeric coefficients. - """ - suborders = {} - for i, ell in group.items(): - suborders.setdefault(ell.order, {})[i] = ell - - for sub in suborders.values(): - directions = numpy.array([ell.direction for ell in sub.values()]) - if len(set(ell.points for ell in sub.values())) > 1: - raise NotImplementedError("Group nodes at different points.") - if directions.shape[0] != directions.shape[1]: - raise NotImplementedError( - "Directions do not span the derivative jet.") - - # coefficients of the direction basis expansion of each multi-index - Dinv = numpy.linalg.inv(directions.T) - for i, ell in sub.items(): - Jd = ell.pullback(J).direction - for col, (j, ellj) in enumerate(sub.items()): - s = _weight_ratio(ell.weights, ellj.weights, tol) - x = s * Dinv[col] - nz = numpy.flatnonzero(abs(x) > tol) - V[i, j] = reduce(add, (Jd[m] * x[m] for m in nz)) if len(nz) else Zero() - processed.add(i) + Away from facets there is no geometric frame, and physical nodes + keep the reference (Cartesian) directions, so the group must span + all directions and acts as its own completion: this is the + affine-interpolation equivalent case, and each pulled-back node is + expanded within the group. + + :arg V: Object array being assembled. + :arg group: Mapping from node index to symbolic PhysicallyMappedFunctional + for the derivative nodes on this entity. + :arg J: GEM expression for the cell Jacobian. + :arg processed: Indices of the already assembled rows; updated in place. + :arg tol: Tolerance for detecting zeros in the numeric coefficients. + """ + suborders = {} + for i, ell in group.items(): + suborders.setdefault(ell.order, {})[i] = ell + + for sub in suborders.values(): + directions = numpy.array([ell.direction for ell in sub.values()]) + if len(set(ell.points for ell in sub.values())) > 1: + raise NotImplementedError("Group nodes at different points.") + if directions.shape[0] != directions.shape[1]: + raise NotImplementedError( + "Directions do not span the derivative jet.") + + # coefficients of the direction basis expansion of each multi-index + Dinv = numpy.linalg.inv(directions.T) + for i, ell in sub.items(): + Jd = ell.pullback(J).direction + for col, (j, ellj) in enumerate(sub.items()): + s = _weight_ratio(ell.weights, ellj.weights, tol) + x = s * Dinv[col] + nz = numpy.flatnonzero(abs(x) > tol) + V[i, j] = reduce(add, (Jd[m] * x[m] for m in nz)) if len(nz) else Zero() + processed.add(i) class PiolaPhysicallyMappedElement(ZanyPhysicallyMappedElement): @@ -423,6 +415,18 @@ class PiolaPhysicallyMappedElement(ZanyPhysicallyMappedElement): their own completion group. """ + @staticmethod + def _check_piola_group(group: dict) -> None: + """Validate that a group of non-invariant Piola-mapped nodes are all + value moments with a nonzero value rank. + + :arg group: Mapping from node index to symbolic PhysicallyMappedFunctional. + :raises NotImplementedError: If a node is a scalar weight or carries + a derivative, which the theory does not yet cover. + """ + if any(ell.rank == 0 or ell.order > 0 for ell in group.values()): + raise NotImplementedError("Cannot yet Piola-transform this node group.") + def _check_mapping(self, fiat_element): mappings = set(fiat_element.mapping()) if mappings not in ({"contravariant piola"}, {"double contravariant piola"}): @@ -430,196 +434,174 @@ def _check_mapping(self, fiat_element): f"{type(self).__name__} expects a (double) contravariant " f"Piola pullback, not {mappings}.") - def _invariant_dofs(self, group, dim, sd): + def invariant_dofs(self, group, dim, sd): # Interior moments are Piola invariant by construction return {i for i, ell in group.items() if ell.order == 0 and dim == sd} - def _facet_dof_rows(self, V, group, fiat_element, entity, J, processed): - _check_piola_group(group) - _piola_facet_rows(V, group, fiat_element, entity, J, processed, self.tol) - - def _point_dof_rows(self, V, group, fiat_element, J, processed): - _check_piola_group(group) - _piola_point_rows(V, group, J, processed, self.tol) - - -def _check_piola_group(group: dict) -> None: - """Validate that a group of non-invariant Piola-mapped nodes are all - value moments with a nonzero value rank. - - :arg group: Mapping from node index to symbolic PhysicallyMappedFunctional. - :raises NotImplementedError: If a node is a scalar weight or carries - a derivative, which the theory does not yet cover. - """ - if any(ell.rank == 0 or ell.order > 0 for ell in group.values()): - raise NotImplementedError("Cannot yet Piola-transform this node group.") - - -def _piola_facet_rows(V: numpy.ndarray, group: dict, - fiat_element: FiniteElement, entity: int, J: Node, - processed: set, tol: float) -> None: - r"""Assemble the rows of V for facet moments of Piola-mapped values. - - This mirrors :func:`_scalar_facet_rows` with the roles of the normal - and tangential directions exchanged: under the contravariant Piola - map the scaled facet normal is the image of the reference one under - the cofactor matrix :math:`K = \operatorname{adj}(J)^T`, so pure - normal moments are invariant, while the scaled tangents map by - :math:`J`. Distinct nodes on a facet can share the same tangential - directions (e.g. two RT-type facet dofs in 3D) and are only told - apart by how their weight varies from point to point, so a node is - identified by its full per-point profile of frame coordinates, not - by direction alone; the pulled-back reference node mixes the - coordinates through the frame expansion of :math:`K\hat{t}_k`, the - tangential profiles are matched within the group, and the residual - normal profile is eliminated numerically through already assembled - rows of V. - - :arg V: Object array being assembled. - :arg group: Mapping from node index to symbolic PhysicallyMappedFunctional - for the value moments on this facet. - :arg fiat_element: The FIAT element. - :arg entity: The facet number. - :arg J: GEM expression for the cell Jacobian. - :arg processed: Indices of the already assembled rows; updated in place. - :arg tol: Tolerance for detecting zeros in the numeric coefficients. - """ - ref_el = fiat_element.get_reference_element() - sd = ref_el.get_spatial_dimension() - that = ref_el.compute_tangents(sd - 1, entity) - nhat = ref_el.compute_scaled_normal(entity) - Ghat = numpy.column_stack([nhat, *that]) - Ghatinv = numpy.linalg.inv(Ghat) - - Jnp = numpy.array([[J[i, k] for k in range(sd)] for i in range(sd)], - dtype=object) - K = adjugate(Jnp).T - - # Reference frame coordinate profiles, shared with the physical nodes: - # each node's own quadrature weights, decomposed into (normal, - # tangential) frame coordinates at each of its points. This is what - # distinguishes nodes with the same tangential directions from one - # another (see the point-dependence note in the docstring above). - coords = {} - for i, ell in group.items(): - C = ell.weights.reshape(-1, *(sd,) * ell.rank) - for _ in range(ell.rank): - C = numpy.tensordot(C, Ghatinv, axes=(1, 1)) - coords[i] = C.reshape(len(ell.points), -1) - # Pure normal moments are Piola invariant - if numpy.allclose(coords[i][:, 1:], 0, atol=tol): + def facet_dof_rows(self, V: numpy.ndarray, group: dict, + fiat_element: FiniteElement, entity: int, J: Node, + processed: set, tol: float) -> None: + r"""Assemble the rows of V for facet moments of Piola-mapped values. + + This mirrors :func:`_scalar_facet_rows` with the roles of the normal + and tangential directions exchanged: under the contravariant Piola + map the scaled facet normal is the image of the reference one under + the cofactor matrix :math:`K = \operatorname{adj}(J)^T`, so pure + normal moments are invariant, while the scaled tangents map by + :math:`J`. Distinct nodes on a facet can share the same tangential + directions (e.g. two RT-type facet dofs in 3D) and are only told + apart by how their weight varies from point to point, so a node is + identified by its full per-point profile of frame coordinates, not + by direction alone; the pulled-back reference node mixes the + coordinates through the frame expansion of :math:`K\hat{t}_k`, the + tangential profiles are matched within the group, and the residual + normal profile is eliminated numerically through already assembled + rows of V. + + :arg V: Object array being assembled. + :arg group: Mapping from node index to symbolic PhysicallyMappedFunctional + for the value moments on this facet. + :arg fiat_element: The FIAT element. + :arg entity: The facet number. + :arg J: GEM expression for the cell Jacobian. + :arg processed: Indices of the already assembled rows; updated in place. + :arg tol: Tolerance for detecting zeros in the numeric coefficients. + """ + PiolaPhysicallyMappedElement._check_piola_group(group) + ref_el = fiat_element.get_reference_element() + sd = ref_el.get_spatial_dimension() + that = ref_el.compute_tangents(sd - 1, entity) + nhat = ref_el.compute_scaled_normal(entity) + Ghat = numpy.column_stack([nhat, *that]) + Ghatinv = numpy.linalg.inv(Ghat) + + Jnp = numpy.array([[J[i, k] for k in range(sd)] for i in range(sd)], + dtype=object) + Knp = adjugate(Jnp).T + + # Reference frame coordinate profiles, shared with the physical nodes: + # each node's own quadrature weights, decomposed into (normal, + # tangential) frame coordinates at each of its points. This is what + # distinguishes nodes with the same tangential directions from one + # another (see the point-dependence note in the docstring above). + coords = {} + for i, ell in group.items(): + C = ell.weights.reshape(-1, *(sd,) * ell.rank) + for _ in range(ell.rank): + C = numpy.tensordot(C, Ghatinv, axes=(1, 1)) + coords[i] = C.reshape(len(ell.points), -1) + # Pure normal moments are Piola invariant + if numpy.allclose(coords[i][:, 1:], 0, atol=tol): + processed.add(i) + + group = {i: ell for i, ell in group.items() if i not in processed} + if not group: + return + rank, = {ell.rank for ell in group.values()} + points, = {ell.points for ell in group.values()} + + # Frame coordinates of the mapped frame image of the reference frame: + # the normal is invariant and the pulled tangents are expanded by a + # symbolic solve in the mapped frame [K nhat | J that_k] + Kn = nhat @ Knp.T + Jt = that @ Jnp.T + A = numpy.column_stack([Kn, *Jt]) + Y = numpy.full((sd, sd), Zero(), dtype=object) + Y[0, 0] = Literal(1.0) + Y[:, 1:] = inverse(A) @ (Knp @ that.T) + + # Physical tangential components are built on the reciprocal basis + # (cross products of the frame), so they carry the in-plane + # contravariant transformation S = adj(G Ghat^{-1})^T of the change + # of tangent Gram matrices. Absorb S^{-1} into the coordinate + # mixing so that the physical profiles keep the reference + # coordinates; in 2D the tangent plane is one-dimensional and S = 1. + G = Jt @ Jt.T + Ghat_t = that @ that.T + Sinv = (numpy.linalg.inv(Ghat_t) @ G) * \ + (numpy.linalg.det(Ghat_t) / determinant(G)) + Y[1:, :] = Sinv @ Y[1:, :] + + # Numeric matching of the tangential coordinate profiles in the group. + # B has full row rank by unisolvence, so the Gram matrix B @ B.T is + # square and invertible; a rank deficiency (a genuine bug) surfaces + # as a LinAlgError here rather than a silent least-squares fit. + B = numpy.array([coords[j][:, 1:].ravel() for j in group]) + Binv = numpy.linalg.inv(B @ B.T) @ B + Binv[abs(Binv) < tol] = 0 + + # Numeric elimination of the normal profile: one pure normal moment + # per quadrature point, evaluated on the nodal basis + ndir = numpy.ones(()) + for _ in range(rank): + ndir = numpy.multiply.outer(ndir, nhat) + T = fiat_element.tabulate(0, points)[(0,) * sd] + L = numpy.einsum("jcq,c->jq", T.reshape(T.shape[0], -1, len(points)), + ndir.ravel()) + L[abs(L) < tol] = 0 + + for i, ell in group.items(): + # Pull back the coordinate profile, contracting each slot with Y + P = coords[i].reshape(-1, *(sd,) * rank) + for _ in range(rank): + P = numpy.tensordot(P, Y, axes=(1, 1)) + P = P.reshape(len(points), -1) + + c = Binv @ P[:, 1:].ravel() + for cj, j in zip(c, group): + V[i, j] = cj + # Residual normal profile after removing the group contribution + residual = P[:, 0] - c @ numpy.array([coords[j][:, 0] for j in group]) + for q in range(len(points)): + for m in numpy.flatnonzero(L[:, q]): + if m not in processed: + raise NotImplementedError( + f"Completion of node {i} couples to node {m}, " + "which has not been transformed yet.") + V[i] += V[m] * (residual[q] * L[m, q]) processed.add(i) - group = {i: ell for i, ell in group.items() if i not in processed} - if not group: - return - rank, = {ell.rank for ell in group.values()} - points, = {ell.points for ell in group.values()} - - # Frame coordinates of the mapped frame image of the reference frame: - # the normal is invariant and the pulled tangents are expanded by a - # symbolic solve in the mapped frame [K nhat | J that_k] - A = numpy.column_stack([K @ nhat, *(Jnp @ t for t in that)]) - adjA = adjugate(A) - detA = determinant(A) - Y = numpy.full((sd, sd), Zero(), dtype=object) - Y[0, 0] = Literal(1.0) - for k, t in enumerate(that): - Y[:, k + 1] = (adjA @ (K @ t)) / detA - - # Physical tangential components are built on the reciprocal basis - # (cross products of the frame), so they carry the in-plane - # contravariant transformation S = adj(G Ghat^{-1})^T of the change - # of tangent Gram matrices. Absorb S^{-1} into the coordinate - # mixing so that the physical profiles keep the reference - # coordinates; in 2D the tangent plane is one-dimensional and S = 1. - G = numpy.array([[Jnp @ t1 @ (Jnp @ t2) for t2 in that] for t1 in that]) - Ghat_t = that @ that.T - Sinv = (numpy.linalg.inv(Ghat_t) @ G) * \ - (numpy.linalg.det(Ghat_t) / determinant(G)) - Y[1:, :] = Sinv @ Y[1:, :] - - # Numeric matching of the tangential coordinate profiles in the group. - # B has full row rank by unisolvence, so the Gram matrix B @ B.T is - # square and invertible; a rank deficiency (a genuine bug) surfaces - # as a LinAlgError here rather than a silent least-squares fit. - B = numpy.array([coords[j][:, 1:].ravel() for j in group]) - Binv = numpy.linalg.inv(B @ B.T) @ B - Binv[abs(Binv) < tol] = 0 - - # Numeric elimination of the normal profile: one pure normal moment - # per quadrature point, evaluated on the nodal basis - ndir = numpy.ones(()) - for _ in range(rank): - ndir = numpy.multiply.outer(ndir, nhat) - T = fiat_element.tabulate(0, points)[(0,) * sd] - L = numpy.einsum("jcq,c->jq", T.reshape(T.shape[0], -1, len(points)), - ndir.ravel()) - L[abs(L) < tol] = 0 - - for i, ell in group.items(): - # Pull back the coordinate profile, contracting each slot with Y - P = coords[i].reshape(-1, *(sd,) * rank) - for _ in range(rank): - P = numpy.tensordot(P, Y, axes=(1, 1)) - P = P.reshape(len(points), -1) - - row = numpy.full(V.shape[1], Zero(), dtype=object) - c = Binv @ P[:, 1:].ravel() - for cj, j in zip(c, group): - row[j] = cj - # Residual normal profile after removing the group contribution - residual = P[:, 0] - c @ numpy.array([coords[j][:, 0] for j in group]) - for q in range(len(points)): - for m in numpy.flatnonzero(L[:, q]): - if m not in processed: - raise NotImplementedError( - f"Completion of node {i} couples to node {m}, " - "which has not been transformed yet.") - row = row + V[m, :] * (residual[q] * L[m, q]) - V[i, :] = row - processed.add(i) - - -def _piola_point_rows(V: numpy.ndarray, group: dict, J: Node, - processed: set, tol: float) -> None: - r"""Assemble the rows of V for point values of Piola-mapped fields. - - This mirrors :func:`_scalar_point_rows`: away from facets, physical - point evaluations keep the reference (Cartesian) components, which - pull back through the cofactor matrix :math:`K = \operatorname{adj}(J)^T` - of the contravariant Piola map, so the group of components at each - point acts as its own completion. - - :arg V: Object array being assembled. - :arg group: Mapping from node index to symbolic PhysicallyMappedFunctional - for the value nodes on this entity. - :arg J: GEM expression for the cell Jacobian. - :arg processed: Indices of the already assembled rows; updated in place. - :arg tol: Tolerance for detecting zeros in the numeric coefficients. - """ - sd = J.shape[0] - Jnp = numpy.array([[J[i, k] for k in range(sd)] for i in range(sd)], - dtype=object) - K = adjugate(Jnp).T - - subgroups = {} - for i, ell in group.items(): - if len(ell.points) != 1 or ell.rank != 1: - raise NotImplementedError( - "Only single-point vector evaluations are handled.") - subgroups.setdefault(ell.points, {})[i] = ell + def point_dof_rows(self, V: numpy.ndarray, group: dict, + fiat_element: FiniteElement, entity: int, J: Node, + processed: set, tol: float) -> None: + r"""Assemble the rows of V for point values of Piola-mapped fields. - for sub in subgroups.values(): - directions = numpy.array([ell.weights[0] for ell in sub.values()]) - if directions.shape[0] != directions.shape[1]: - raise NotImplementedError( - "Directions do not span the vector components.") - Dinv = numpy.linalg.inv(directions.T) - for i, ell in sub.items(): - Kd = K @ ell.weights[0] - for col, j in enumerate(sub): - x = Dinv[col] - nz = numpy.flatnonzero(abs(x) > tol) - V[i, j] = reduce(add, (Kd[m] * x[m] for m in nz)) if len(nz) else Zero() - processed.add(i) + This mirrors :func:`_scalar_point_rows`: away from facets, physical + point evaluations keep the reference (Cartesian) components, which + pull back through the cofactor matrix :math:`K = \operatorname{adj}(J)^T` + of the contravariant Piola map, so the group of components at each + point acts as its own completion. + + :arg V: Object array being assembled. + :arg group: Mapping from node index to symbolic PhysicallyMappedFunctional + for the value nodes on this entity. + :arg J: GEM expression for the cell Jacobian. + :arg processed: Indices of the already assembled rows; updated in place. + :arg tol: Tolerance for detecting zeros in the numeric coefficients. + """ + PiolaPhysicallyMappedElement._check_piola_group(group) + sd = J.shape[0] + Jnp = numpy.array([[J[i, k] for k in range(sd)] for i in range(sd)], + dtype=object) + K = adjugate(Jnp).T + + subgroups = {} + for i, ell in group.items(): + if len(ell.points) != 1 or ell.rank != 1: + raise NotImplementedError( + "Only single-point vector evaluations are handled.") + subgroups.setdefault(ell.points, {})[i] = ell + + for sub in subgroups.values(): + directions = numpy.array([ell.weights[0] for ell in sub.values()]) + if directions.shape[0] != directions.shape[1]: + raise NotImplementedError( + "Directions do not span the vector components.") + Dinv = numpy.linalg.inv(directions.T) + for i, ell in sub.items(): + Kd = K @ ell.weights[0] + for col, j in enumerate(sub): + x = Dinv[col] + nz = numpy.flatnonzero(abs(x) > tol) + V[i, j] = reduce(add, (Kd[m] * x[m] for m in nz)) if len(nz) else Zero() + processed.add(i) diff --git a/test/finat/conftest.py b/test/finat/conftest.py index a3288190a..c31ef7350 100644 --- a/test/finat/conftest.py +++ b/test/finat/conftest.py @@ -1,11 +1,8 @@ -import pprint - import pytest import FIAT import gem import numpy as np -from gem.interpreter import evaluate -from finat.physically_mapped import PhysicalGeometry, PhysicallyMappedElement +from finat.physically_mapped import PhysicalGeometry class MyMapping(PhysicalGeometry): @@ -129,103 +126,3 @@ def ref_to_phys(ref_el, phys_el): def scaled_ref_to_phys(ref_el): return {dim: [ScaledMapping(ref_el[dim], scaled_simplex(dim, 0.5**k)) for k in range(3)] for dim in ref_el} - - -def make_unisolvent_points(element, interior=False): - degree = element.degree() - ref_complex = element.get_reference_complex() - top = ref_complex.get_topology() - pts = [] - if interior: - dim = ref_complex.get_spatial_dimension() - for entity in top[dim]: - pts.extend(ref_complex.make_points(dim, entity, degree+dim+1, variant="gll")) - else: - for dim in top: - for entity in top[dim]: - pts.extend(ref_complex.make_points(dim, entity, degree, variant="gll")) - return pts - - -def check_zany_mapping(element, ref_to_phys, *args, **kwargs): - phys_cell = ref_to_phys.phys_cell - ref_cell = ref_to_phys.ref_cell - phys_element = element(phys_cell, *args, **kwargs).fiat_equivalent - finat_element = element(ref_cell, *args, **kwargs) - - ref_element = finat_element._element - ref_cell = ref_element.get_reference_element() - phys_cell = phys_element.get_reference_element() - sd = ref_cell.get_spatial_dimension() - - shape = ref_element.value_shape() - ref_pts = make_unisolvent_points(ref_element, interior=True) - ref_vals = ref_element.tabulate(0, ref_pts)[(0,)*sd] - - phys_pts = make_unisolvent_points(phys_element, interior=True) - phys_vals = phys_element.tabulate(0, phys_pts)[(0,)*sd] - - mapping = ref_element.mapping()[0] - if mapping == "affine": - ref_vals_piola = ref_vals - else: - # Piola map the reference elements - J, b = FIAT.reference_element.make_affine_mapping(ref_cell.vertices, - phys_cell.vertices) - K = [] - if "covariant" in mapping: - K.append(np.linalg.inv(J).T) - if "contravariant" in mapping: - K.append(J / np.linalg.det(J)) - - if len(shape) == 2: - piola_map = lambda x: K[0] @ x @ K[-1].T - else: - piola_map = lambda x: K[0] @ x - - ref_vals_piola = np.zeros(ref_vals.shape) - for i in range(ref_vals.shape[0]): - for k in range(ref_vals.shape[-1]): - ref_vals_piola[i, ..., k] = piola_map(ref_vals[i, ..., k]) - - # Zany map the results - num_bfs = phys_element.space_dimension() - num_dofs = finat_element.space_dimension() - if isinstance(finat_element, PhysicallyMappedElement): - Mgem = finat_element.basis_transformation(ref_to_phys) - M = evaluate([Mgem])[0].arr - ref_vals_zany = np.tensordot(M, ref_vals_piola, (-1, 0)) - else: - M = np.eye(num_dofs, num_bfs) - ref_vals_zany = ref_vals_piola - - # Solve for the basis transformation and compare results - Phi = ref_vals_piola.reshape(num_bfs, -1) - phi = phys_vals.reshape(num_bfs, -1) - Vh, residual, *_ = np.linalg.lstsq(Phi.T, phi.T) - Mh = Vh.T - Mh = Mh[:num_dofs] - tol = 1E-10 - Mh[abs(Mh) < tol] = 0 - M[abs(M) < tol] = 0 - - delta = M.T - Mh.T - delta[abs(delta) < tol] = 0 - with np.errstate(divide='ignore', invalid='ignore'): - error = delta / Mh.T - error[error != error] = 0 - error[delta == 0] = 0 - error[abs(error) < tol] = 0 - - inds = tuple(map(np.unique, np.nonzero(error))) - error = error[np.ix_(*inds)] - error[error != 0] += 1 - - pp = pprint.PrettyPrinter(width=140, compact=True) - assert np.allclose(residual, 0), pp.pformat((np.round(error, 8).tolist(), *inds)) - assert np.allclose(ref_vals_zany, phys_vals[:num_dofs]), pp.pformat((np.round(error, 8).tolist(), *inds)) - - -@pytest.fixture(name="check_zany_mapping") -def check_zany_mapping_fixture(): - return check_zany_mapping diff --git a/test/finat/test_zany_mapping.py b/test/finat/test_zany_mapping.py index 95524b6a7..9220dca99 100644 --- a/test/finat/test_zany_mapping.py +++ b/test/finat/test_zany_mapping.py @@ -1,5 +1,106 @@ +import FIAT import finat +import numpy as np import pytest +import pprint + +from gem.interpreter import evaluate +from finat.physically_mapped import PhysicallyMappedElement + + +def make_unisolvent_points(element, interior=False): + degree = element.degree() + ref_complex = element.get_reference_complex() + top = ref_complex.get_topology() + pts = [] + if interior: + dim = ref_complex.get_spatial_dimension() + for entity in top[dim]: + pts.extend(ref_complex.make_points(dim, entity, degree+dim+1, variant="gll")) + else: + for dim in top: + for entity in top[dim]: + pts.extend(ref_complex.make_points(dim, entity, degree, variant="gll")) + return pts + + +def check_zany_mapping(element, ref_to_phys, *args, **kwargs): + phys_cell = ref_to_phys.phys_cell + ref_cell = ref_to_phys.ref_cell + phys_element = element(phys_cell, *args, **kwargs).fiat_equivalent + finat_element = element(ref_cell, *args, **kwargs) + + ref_element = finat_element._element + ref_cell = ref_element.get_reference_element() + phys_cell = phys_element.get_reference_element() + sd = ref_cell.get_spatial_dimension() + + shape = ref_element.value_shape() + ref_pts = make_unisolvent_points(ref_element, interior=True) + ref_vals = ref_element.tabulate(0, ref_pts)[(0,)*sd] + + phys_pts = make_unisolvent_points(phys_element, interior=True) + phys_vals = phys_element.tabulate(0, phys_pts)[(0,)*sd] + + mapping = ref_element.mapping()[0] + if mapping == "affine": + ref_vals_piola = ref_vals + else: + # Piola map the reference elements + J, b = FIAT.reference_element.make_affine_mapping(ref_cell.vertices, + phys_cell.vertices) + K = [] + if "covariant" in mapping: + K.append(np.linalg.inv(J).T) + if "contravariant" in mapping: + K.append(J / np.linalg.det(J)) + + if len(shape) == 2: + piola_map = lambda x: K[0] @ x @ K[-1].T + else: + piola_map = lambda x: K[0] @ x + + ref_vals_piola = np.zeros(ref_vals.shape) + for i in range(ref_vals.shape[0]): + for k in range(ref_vals.shape[-1]): + ref_vals_piola[i, ..., k] = piola_map(ref_vals[i, ..., k]) + + # Zany map the results + num_bfs = phys_element.space_dimension() + num_dofs = finat_element.space_dimension() + if isinstance(finat_element, PhysicallyMappedElement): + Mgem = finat_element.basis_transformation(ref_to_phys) + M = evaluate([Mgem])[0].arr + ref_vals_zany = np.tensordot(M, ref_vals_piola, (-1, 0)) + else: + M = np.eye(num_dofs, num_bfs) + ref_vals_zany = ref_vals_piola + + # Solve for the basis transformation and compare results + Phi = ref_vals_piola.reshape(num_bfs, -1) + phi = phys_vals.reshape(num_bfs, -1) + Vh, residual, *_ = np.linalg.lstsq(Phi.T, phi.T) + Mh = Vh.T + Mh = Mh[:num_dofs] + tol = 1E-10 + Mh[abs(Mh) < tol] = 0 + M[abs(M) < tol] = 0 + + delta = M.T - Mh.T + delta[abs(delta) < tol] = 0 + with np.errstate(divide='ignore', invalid='ignore'): + error = delta / Mh.T + error[error != error] = 0 + error[delta == 0] = 0 + error[abs(error) < tol] = 0 + + inds = tuple(map(np.unique, np.nonzero(error))) + error = error[np.ix_(*inds)] + error[error != 0] += 1 + + pp = pprint.PrettyPrinter(width=140, compact=True) + assert np.allclose(residual, 0), pp.pformat((np.round(error, 8).tolist(), *inds)) + assert np.allclose(ref_vals_zany, phys_vals[:num_dofs]), pp.pformat((np.round(error, 8).tolist(), *inds)) @pytest.mark.parametrize("element", [ @@ -9,7 +110,7 @@ finat.WuXuH3NC, finat.WuXuRobustH3NC, ]) -def test_C1_triangle(check_zany_mapping, ref_to_phys, element): +def test_C1_triangle(ref_to_phys, element): check_zany_mapping(element, ref_to_phys[2]) @@ -17,7 +118,7 @@ def test_C1_triangle(check_zany_mapping, ref_to_phys, element): finat.Morley, finat.Walkington, ]) -def test_C1_tetrahedron(check_zany_mapping, ref_to_phys, element): +def test_C1_tetrahedron(ref_to_phys, element): check_zany_mapping(element, ref_to_phys[3]) @@ -26,7 +127,7 @@ def test_C1_tetrahedron(check_zany_mapping, ref_to_phys, element): finat.QuadraticPowellSabin12, finat.ReducedHsiehCloughTocher, ]) -def test_C1_macroelements(check_zany_mapping, ref_to_phys, element): +def test_C1_macroelements(ref_to_phys, element): kwargs = {} if element == finat.QuadraticPowellSabin12: kwargs = dict(avg=True) @@ -39,11 +140,11 @@ def test_C1_macroelements(check_zany_mapping, ref_to_phys, element): *((finat.AlfeldC2, k) for k in range(5, 7)), *((finat.BrambleZlamalC2, k) for k in range(9, 11)), ]) -def test_high_order_Ck_elements(check_zany_mapping, ref_to_phys, element, degree): +def test_high_order_Ck_elements(ref_to_phys, element, degree): check_zany_mapping(element, ref_to_phys[2], degree, avg=True) -def test_argyris_point(check_zany_mapping, ref_to_phys): +def test_argyris_point(ref_to_phys): check_zany_mapping(finat.Argyris, ref_to_phys[2], variant="point") @@ -73,7 +174,7 @@ def test_argyris_point(check_zany_mapping, ref_to_phys): *((2, e) for e in zany_piola_elements[3]), *((3, e) for e in zany_piola_elements[3]), ]) -def test_piola(check_zany_mapping, ref_to_phys, element, dimension): +def test_piola(ref_to_phys, element, dimension): check_zany_mapping(element, ref_to_phys[dimension]) @@ -81,14 +182,14 @@ def test_piola(check_zany_mapping, ref_to_phys, element, dimension): (3, finat.MardalTaiWinther, 2), (3, finat.GuzmanNeilanFirstKindH1, 2), ]) -def test_high_order_stokes_elements(check_zany_mapping, ref_to_phys, element, dimension, degree): +def test_high_order_stokes_elements(ref_to_phys, element, dimension, degree): check_zany_mapping(element, ref_to_phys[dimension], degree) @pytest.mark.parametrize("element, degree, variant", [ *((finat.HuZhang, k, v) for v in ("integral", "point") for k in range(3, 6)), ]) -def test_piola_triangle_high_order(check_zany_mapping, ref_to_phys, element, degree, variant): +def test_piola_triangle_high_order(ref_to_phys, element, degree, variant): check_zany_mapping(element, ref_to_phys[2], degree, variant) @@ -100,7 +201,7 @@ def test_piola_triangle_high_order(check_zany_mapping, ref_to_phys, element, deg ]) @pytest.mark.parametrize("dimension", [2, 3]) @pytest.mark.parametrize("variant", [None, "alfeld"]) -def test_affine(check_zany_mapping, ref_to_phys, element, degree, variant, dimension): +def test_affine(ref_to_phys, element, degree, variant, dimension): check_zany_mapping(element, ref_to_phys[dimension], degree, variant=variant) @@ -108,5 +209,5 @@ def test_affine(check_zany_mapping, ref_to_phys, element, degree, variant, dimen @pytest.mark.parametrize("degree", [1, 2]) @pytest.mark.parametrize("dimension", [2, 3]) @pytest.mark.parametrize("variant", [None, "iso"]) -def test_macro_piola(check_zany_mapping, ref_to_phys, element, degree, variant, dimension): +def test_macro_piola(ref_to_phys, element, degree, variant, dimension): check_zany_mapping(element, ref_to_phys[dimension], degree, variant=variant) From 08f36aa1f179da43c8009560169e93b3c212a291 Mon Sep 17 00:00:00 2001 From: Pablo Brubeck Date: Wed, 15 Jul 2026 18:15:02 +0100 Subject: [PATCH 21/34] Fix piola sparsity --- finat/zany.py | 54 +++++++++++++++++++++++++++++++++++++++----------- zany_claude.md | 27 ++++++++++++++++++++++++- 2 files changed, 68 insertions(+), 13 deletions(-) diff --git a/finat/zany.py b/finat/zany.py index 08f495b66..676043265 100644 --- a/finat/zany.py +++ b/finat/zany.py @@ -43,7 +43,7 @@ import numpy from FIAT.finite_element import FiniteElement -from gem import Literal, ListTensor, Node, Power, Zero +from gem import Literal, ListTensor, Node, Zero from finat.functional import PhysicallyMappedFunctional from finat.physically_mapped import PhysicallyMappedElement, adjugate, determinant, identity, inverse @@ -104,8 +104,7 @@ def __init__(self, fiat_element: FiniteElement, entity: int, J: Node): self._adjA = adjugate(A) self._detA = determinant(A) - normC = Power(reduce(add, (C[i] * C[i] for i in range(sd))), - Literal(0.5)) + normC = (C @ C) ** 0.5 self.normal_scale = normC / kappa vol = ref_el.volume_of_subcomplex(sd - 1, entity) self.measure = normC * (vol / numpy.linalg.norm(Chat)) @@ -506,6 +505,7 @@ def facet_dof_rows(self, V: numpy.ndarray, group: dict, Kn = nhat @ Knp.T Jt = that @ Jnp.T A = numpy.column_stack([Kn, *Jt]) + Y = numpy.full((sd, sd), Zero(), dtype=object) Y[0, 0] = Literal(1.0) Y[:, 1:] = inverse(A) @ (Knp @ that.T) @@ -530,8 +530,19 @@ def facet_dof_rows(self, V: numpy.ndarray, group: dict, Binv = numpy.linalg.inv(B @ B.T) @ B Binv[abs(Binv) < tol] = 0 - # Numeric elimination of the normal profile: one pure normal moment - # per quadrature point, evaluated on the nodal basis + # Numeric elimination of the residual normal profile against every + # basis function of the element (not just this facet's own normal + # dofs, since a completing dof may live on a different point set, + # e.g. Guzman-Neilan's mixed-order facet dofs). The quadrature-point + # axis is contracted purely numerically here, one reference-frame + # multi-index at a time, so that whether a coupling is present is + # decided from a plain numeric array (exact zero where a profile has + # no support at these points) rather than by inspecting the type of + # a symbolic GEM expression -- a sum of symbolic terms that is + # identically zero for all J need not reduce to a literal + # gem.Zero() node, so testing that structurally would either miss + # real cancellations or (as here) spuriously keep terms whose + # numeric coefficient actually vanishes. ndir = numpy.ones(()) for _ in range(rank): ndir = numpy.multiply.outer(ndir, nhat) @@ -539,6 +550,12 @@ def facet_dof_rows(self, V: numpy.ndarray, group: dict, L = numpy.einsum("jcq,c->jq", T.reshape(T.shape[0], -1, len(points)), ndir.ravel()) L[abs(L) < tol] = 0 + # Lmap[i][m, r] = sum_q L[m, q] * coords[i][q, r]: numeric coupling + # of every basis function to each frame coordinate of node i's own + # profile, contracted over the shared quadrature points. + Lmap = {i: L @ coords[i] for i in group} + for i in Lmap: + Lmap[i][abs(Lmap[i]) < tol] = 0 for i, ell in group.items(): # Pull back the coordinate profile, contracting each slot with Y @@ -548,17 +565,30 @@ def facet_dof_rows(self, V: numpy.ndarray, group: dict, P = P.reshape(len(points), -1) c = Binv @ P[:, 1:].ravel() - for cj, j in zip(c, group): - V[i, j] = cj - # Residual normal profile after removing the group contribution - residual = P[:, 0] - c @ numpy.array([coords[j][:, 0] for j in group]) - for q in range(len(points)): - for m in numpy.flatnonzero(L[:, q]): + V[i, list(group)] = c + + # Couple the residual normal profile to every basis function, + # one reference-frame multi-index (or group member) at a time: + # each numeric column of Lmap decides its own sparsity, and the + # small symbolic frame-mixing coefficient only scales terms + # that already survived the numeric test. + terms = [] + for index in numpy.ndindex(*(sd,) * rank): + flat = numpy.ravel_multi_index(index, (sd,) * rank) + coef = Literal(1.0) + for r in index: + coef = coef * Y[0, r] + terms.append((Lmap[i][:, flat], coef)) + for k, j in enumerate(group): + terms.append((-Lmap[j][:, 0], c[k])) + + for vec, coef in terms: + for m in numpy.flatnonzero(vec): if m not in processed: raise NotImplementedError( f"Completion of node {i} couples to node {m}, " "which has not been transformed yet.") - V[i] += V[m] * (residual[q] * L[m, q]) + V[i] += V[m] * (vec[m] * coef) processed.add(i) def point_dof_rows(self, V: numpy.ndarray, group: dict, diff --git a/zany_claude.md b/zany_claude.md index de39d5edd..aed946952 100644 --- a/zany_claude.md +++ b/zany_claude.md @@ -186,7 +186,32 @@ stacks the group's own reference tangential profiles and has full row rank by unisolvence, so $B B^T$ is invertible and a rank deficiency (a genuine bug) surfaces as a hard numerical error rather than passing silently; the residual normal profile is eliminated by per-point normal moments through the Vandermonde recursion (this is where -e.g. tangential-to-normal couplings emerge). Key subtlety (in any dimension > 2): FIAT builds tangential value +e.g. tangential-to-normal couplings emerge). + +**Sparsity gotcha: fold the quadrature-point sum numerically, never symbolically.** The +naive way to do that last elimination is to build the per-point residual (a GEM +expression, since it involves the symbolic mixing matrix $Y$) and dot it against the +tabulated-basis row `L[m, :]` (numeric) with a Python loop over points, accumulating +into `V[i] += V[m] * (residual[q] * L[m, q])`. This reproduces the right *numbers* but +not the right *sparsity*: whenever the true coupling to some dof `m` is zero, it is zero +only as an analytic identity in $J$ (a cancellation across quadrature points or across +frame directions), not as a literal `gem.Zero()` node — GEM has no polynomial-identity +simplifier, so `isinstance(x, gem.Zero)` (or eyeballing `x != 0`) cannot detect this, and +the resulting matrix keeps a dense cloud of numerically-tiny-but-symbolically-nonzero +entries (compare against the hand-coded matrix's exact sparsity, e.g. MTW's +`normal_tangential_transform`, to see the gap). The fix: contract the quadrature-point +axis while everything is still numeric — `Lmap[i] = L @ coords[i]` (both plain arrays) +— and only *afterwards* multiply by the (few, small) symbolic frame-mixing scalars +($Y[0, r]$, or $-c_j$ for each group member's own normal profile), one reference-frame +multi-index/group-member at a time rather than summed together first. Sparsity is then +decided by thresholding the numeric `Lmap` columns (`abs(...) < tol`), exactly the same +idiom already used for `Binv`/`B` above; a symbolic sum of several *different* GEM +scalars must never be built first and then inspected for zero-ness, since that sum's +GEM shape does not reveal whether it is analytically zero. Verified by comparing bit-for- +bit against the deleted hand-coded `MardalTaiWinther.basis_transformation` sparsity +pattern for order 1 (2D/3D) and order 2 (3D): zero extra and zero missing entries. + +Key subtlety (in any dimension > 2): FIAT builds tangential value components on the **reciprocal basis** (`cross(n, t_k)`), which transforms in-plane contravariantly: absorb $S^{-1} = (\det\hat G_t/\det G_t)\hat G_t^{-1} G_t$ (tangent Gram change) into $Y$'s tangential rows; in 2D $S = 1$, which hides the effect. From 246b4476750c496bda36cbf0d87f6bb146cc5fd3 Mon Sep 17 00:00:00 2001 From: Pablo Brubeck Date: Wed, 15 Jul 2026 18:51:50 +0100 Subject: [PATCH 22/34] H1Div elements --- finat/alfeld_sorokina.py | 40 ++----- finat/functional.py | 39 ++++++- finat/guzman_neilan.py | 11 +- finat/zany.py | 235 ++++++++++++++++++++++++++++----------- zany_claude.md | 39 ++++++- 5 files changed, 259 insertions(+), 105 deletions(-) diff --git a/finat/alfeld_sorokina.py b/finat/alfeld_sorokina.py index c0ccf691f..820faf718 100644 --- a/finat/alfeld_sorokina.py +++ b/finat/alfeld_sorokina.py @@ -1,41 +1,17 @@ import FIAT -import numpy -from gem import ListTensor from finat.citations import cite from finat.fiat_elements import FiatElement -from finat.physically_mapped import identity, PhysicallyMappedElement -from finat.piola_mapped import piola_inverse +from finat.zany import PiolaPhysicallyMappedElement -class AlfeldSorokina(PhysicallyMappedElement, FiatElement): +class AlfeldSorokina(PiolaPhysicallyMappedElement, FiatElement): + """The Alfeld-Sorokina C0 quadratic macroelement with C0 divergence. + + This element belongs to a Stokes complex, and is paired with CG1(Alfeld). + The basis transformation is derived automatically from the FIAT dual + basis; see :class:`finat.zany.PiolaPhysicallyMappedElement`. + """ def __init__(self, cell, degree=2): cite("AlfeldSorokina2016") super().__init__(FIAT.AlfeldSorokina(cell, degree)) - - def basis_transformation(self, coordinate_mapping): - sd = self.cell.get_spatial_dimension() - bary, = self.cell.make_points(sd, 0, sd+1) - J = coordinate_mapping.jacobian_at(bary) - detJ = coordinate_mapping.detJ_at(bary) - - dofs = self.entity_dofs() - V = identity(self.space_dimension()) - - # Undo the Piola transform - nodes = self._element.get_dual_set().nodes - Finv = piola_inverse(self.cell, J, detJ) - for dim in sorted(dofs): - for e in sorted(dofs[dim]): - k = 0 - while k < len(dofs[dim][e]): - cur = dofs[dim][e][k] - if len(nodes[cur].deriv_dict) > 0: - V[cur, cur] = detJ - k += 1 - else: - s = dofs[dim][e][k:k+sd] - V[numpy.ix_(s, s)] = Finv - k += sd - - return ListTensor(V.T) diff --git a/finat/functional.py b/finat/functional.py index 6f585819d..82c0dd606 100644 --- a/finat/functional.py +++ b/finat/functional.py @@ -51,16 +51,27 @@ class PhysicallyMappedFunctional: direction : For ``order > 0``, the direction tensor of rank ``order``, either numeric or a GEM expression; ``None`` for ``order == 0``. + divergence : + Whether this is a divergence functional: the trace, at each + point, of the tensor pairing an order-1 derivative multi-index + with a rank-1 value component. Unlike an ordinary derivative, + this does not have a single ``direction``, since the trace + pattern spans the full derivative and value index ranges; it is + recovered and handled as its own case because it commutes with + the (contravariant) Piola pullback up to the Jacobian + determinant, independently of the entity it sits on. """ def __init__(self, points: tuple, weights: numpy.ndarray, - order: int = 0, direction=None, rank: int = 0): + order: int = 0, direction=None, rank: int = 0, + divergence: bool = False): self.points = points self.weights = weights self.order = order self.direction = direction self.rank = rank + self.divergence = divergence @classmethod def from_fiat(cls, node: FIATFunctional, tol: float = 1e-12) -> "PhysicallyMappedFunctional": @@ -112,12 +123,32 @@ def from_fiat(cls, node: FIATFunctional, tol: float = 1e-12) -> "PhysicallyMappe lookup = {alpha: k for k, alpha in enumerate(alphas)} points = tuple(node.deriv_dict) + has_comp = any(comp != tuple() for pt in points + for w, alpha, comp in node.deriv_dict[pt]) + if has_comp: + # A divergence: at each point, the weights pairing an + # order-1 derivative multi-index with a rank-1 value + # component must form a scalar multiple of the trace. + if order != 1: + raise NotImplementedError( + f"{type(node).__name__} has vector components.") + weights = numpy.zeros(len(points)) + for q, pt in enumerate(points): + Wq = numpy.zeros((sd, sd)) + for w, alpha, comp in node.deriv_dict[pt]: + if len(comp) != 1: + raise NotImplementedError( + f"{type(node).__name__} has vector components.") + Wq[lookup[tuple(alpha)], comp[0]] += w + if not numpy.allclose(Wq, Wq[0, 0] * numpy.eye(sd), atol=tol): + raise NotImplementedError( + f"{type(node).__name__} is not a divergence functional.") + weights[q] = Wq[0, 0] + return cls(points, weights, order=order, divergence=True) + W = numpy.zeros((len(points), len(alphas))) for q, pt in enumerate(points): for w, alpha, comp in node.deriv_dict[pt]: - if comp != tuple(): - raise NotImplementedError( - f"{type(node).__name__} has vector components.") W[q, lookup[tuple(alpha)]] += w # Factor the weights as a common direction times scalar weights diff --git a/finat/guzman_neilan.py b/finat/guzman_neilan.py index 8c01d4ff5..0390925be 100644 --- a/finat/guzman_neilan.py +++ b/finat/guzman_neilan.py @@ -32,8 +32,15 @@ def __init__(self, cell, degree=None, quad_scheme=None): super().__init__(cell, order=0, quad_scheme=quad_scheme) -class GuzmanNeilanH1div(PiolaBubbleElement): - """Alfeld-Sorokina nodally enriched with Guzman-Neilan bubbles.""" +class GuzmanNeilanH1div(PiolaPhysicallyMappedElement, PiolaBubbleElement): + """Alfeld-Sorokina nodally enriched with Guzman-Neilan bubbles. + + The basis transformation is derived automatically from the FIAT + dual basis (see :class:`finat.zany.PiolaPhysicallyMappedElement`); + the trailing tangential facet constraints of the extended element + are dropped from the physical element by + :meth:`~finat.piola_mapped.PiolaBubbleElement.space_dimension`. + """ def __init__(self, cell, degree=None, quad_scheme=None): cite("GuzmanNeilan2018") super().__init__(FIAT.GuzmanNeilanH1div(cell, degree=degree, quad_scheme=quad_scheme)) diff --git a/finat/zany.py b/finat/zany.py index 676043265..276d80f0f 100644 --- a/finat/zany.py +++ b/finat/zany.py @@ -26,10 +26,10 @@ * :class:`PiolaPhysicallyMappedElement`, for vector- or tensor-valued elements under the (double) contravariant Piola pullback -- Mardal-Tai-Winther, Johnson-Mercier, Guzman-Neilan. The roles of the - normal and tangential directions are mirrored: the scaled facet - normal is the cofactor image of the reference one, so pure - normal-component moments are invariant, while scaled tangents map by - the Jacobian. + normal and tangential directions are mirrored (:class:`PiolaFacetFrame`): + the scaled facet normal is the cofactor image of the reference one, so + pure normal-component moments are invariant, while scaled tangents map + by the Jacobian. In the language of the theory, the frame expansion in either mixin realizes :math:`E V^c`, and the numeric elimination of the tangential @@ -132,6 +132,64 @@ def decompose(self, direction: Node) -> list: for m in range(sd)] +class PiolaFacetFrame: + r"""Normal/tangential frame of a facet for the (double) contravariant + Piola pullback, and its expansion under push-forward. + + The roles of the normal and tangential directions are mirrored with + respect to :class:`FacetFrame`: the reference frame's scaled normal + :math:`\hat{n}` maps by the cofactor matrix :math:`K = + \operatorname{adj}(J)^T`, while its tangents :math:`\hat{t}_k` map by + :math:`J` directly. ``Y`` is the (symbolic) matrix expanding the + pulled-back frame image :math:`[K\hat{n}\;|\;K\hat{t}_k]` in the + physical frame :math:`[K\hat{n}\;|\;J\hat{t}_k]`; the mapped tangents + are built on the reciprocal basis in dimension > 2, so their + coordinates carry an extra in-plane contravariant correction + :math:`S^{-1}` folded into ``Y``'s tangential rows. + + :arg fiat_element: The FIAT element, providing the reference cell. + :arg entity: The facet number. + :arg J: GEM expression for the cell Jacobian. + """ + + def __init__(self, fiat_element: FiniteElement, entity: int, J: Node): + ref_el = fiat_element.get_reference_element() + sd = ref_el.get_spatial_dimension() + self.tangents = ref_el.compute_tangents(sd - 1, entity) + self.normal = ref_el.compute_scaled_normal(entity) + + Ghat = numpy.column_stack([self.normal, *self.tangents]) + self.Ghatinv = numpy.linalg.inv(Ghat) + + Jnp = numpy.array([[J[i, k] for k in range(sd)] for i in range(sd)], + dtype=object) + Knp = adjugate(Jnp).T + + # Frame coordinates of the mapped frame image of the reference frame: + # the normal is invariant and the pulled tangents are expanded by a + # symbolic solve in the mapped frame [K nhat | J that_k] + Kn = self.normal @ Knp.T + Jt = self.tangents @ Jnp.T + A = numpy.column_stack([Kn, *Jt]) + + Y = numpy.full((sd, sd), Zero(), dtype=object) + Y[0, 0] = Literal(1.0) + Y[:, 1:] = inverse(A) @ (Knp @ self.tangents.T) + + # Physical tangential components are built on the reciprocal basis + # (cross products of the frame), so they carry the in-plane + # contravariant transformation S = adj(G Ghat^{-1})^T of the change + # of tangent Gram matrices. Absorb S^{-1} into the coordinate + # mixing so that the physical profiles keep the reference + # coordinates; in 2D the tangent plane is one-dimensional and S = 1. + G = Jt @ Jt.T + Ghat_t = self.tangents @ self.tangents.T + Sinv = (numpy.linalg.inv(Ghat_t) @ G) * \ + (numpy.linalg.det(Ghat_t) / determinant(G)) + Y[1:, :] = Sinv @ Y[1:, :] + self.Y = Y + + def _weight_ratio(wi: numpy.ndarray, wj: numpy.ndarray, tol: float) -> float: """Return the scalar s with wi == s * wj, if it exists.""" s = wi @ wj / (wj @ wj) @@ -426,6 +484,97 @@ def _check_piola_group(group: dict) -> None: if any(ell.rank == 0 or ell.order > 0 for ell in group.values()): raise NotImplementedError("Cannot yet Piola-transform this node group.") + @staticmethod + def _divergence_rows(V: numpy.ndarray, group: dict, J: Node, processed: set) -> dict: + r"""Assemble the rows of V for divergence nodes and strip them from the group. + + The (double) contravariant Piola pullback commutes with the + divergence up to the Jacobian determinant, independently of the + entity the node sits on, so each divergence node is its own, + trivially invariant group. + + :arg V: Object array being assembled; rows are set in place. + :arg group: Mapping from node index to symbolic PhysicallyMappedFunctional + for the nodes on this entity. + :arg J: GEM expression for the cell Jacobian. + :arg processed: Indices of the already assembled rows; updated in place. + :returns: The remaining, non-divergence nodes of ``group``. + """ + divs = {i: ell for i, ell in group.items() if ell.divergence} + if divs: + sd = J.shape[0] + Jnp = numpy.array([[J[i, k] for k in range(sd)] for i in range(sd)], + dtype=object) + detJ = determinant(Jnp) + for i, ell in divs.items(): + V[i, i] = detJ * ell.weights[0] + processed.add(i) + return {i: ell for i, ell in group.items() if i not in divs} + + @staticmethod + def _is_cartesian_point_group(group: dict) -> bool: + """Recognize a group of Cartesian point-value nodes sharing one point. + + :arg group: Mapping from node index to symbolic PhysicallyMappedFunctional. + :returns: True if every node is a rank-1, order-0 node at the same + single point, spanning exactly as many components as the group + has members -- the same structural pattern :meth:`_piola_point_rows` + (and :meth:`ScalarPhysicallyMappedElement.point_dof_rows`) expect, + regardless of the entity dimension the group sits on. + """ + points = {ell.points for ell in group.values()} + return (len(points) == 1 and len(next(iter(points))) == 1 + and all(ell.order == 0 and ell.rank == 1 for ell in group.values())) + + @staticmethod + def _piola_point_rows(V: numpy.ndarray, group: dict, J: Node, + processed: set, tol: float) -> None: + r"""Assemble the rows of V for Cartesian point values of Piola-mapped fields. + + Physical point evaluations keep the reference (Cartesian) components, + which pull back through the cofactor matrix :math:`K = + \operatorname{adj}(J)^T` of the contravariant Piola map, so the group + of components sharing a point acts as its own completion. This is + the same treatment regardless of which entity the point sits on: a + single-point, rank-1 node group spanning the Cartesian components is + not a facet moment (whose weights are genuine, usually multi-point + quadrature averages aligned with the facet normal/tangential frame) + even when it happens to sit on a codimension-1 entity, e.g. the edge + dofs of :class:`~finat.alfeld_sorokina.AlfeldSorokina`. + + :arg V: Object array being assembled. + :arg group: Mapping from node index to symbolic PhysicallyMappedFunctional + for the value nodes on this entity. + :arg J: GEM expression for the cell Jacobian. + :arg processed: Indices of the already assembled rows; updated in place. + :arg tol: Tolerance for detecting zeros in the numeric coefficients. + """ + sd = J.shape[0] + Jnp = numpy.array([[J[i, k] for k in range(sd)] for i in range(sd)], + dtype=object) + K = adjugate(Jnp).T + + subgroups = {} + for i, ell in group.items(): + if len(ell.points) != 1 or ell.rank != 1: + raise NotImplementedError( + "Only single-point vector evaluations are handled.") + subgroups.setdefault(ell.points, {})[i] = ell + + for sub in subgroups.values(): + directions = numpy.array([ell.weights[0] for ell in sub.values()]) + if directions.shape[0] != directions.shape[1]: + raise NotImplementedError( + "Directions do not span the vector components.") + Dinv = numpy.linalg.inv(directions.T) + for i, ell in sub.items(): + Kd = K @ ell.weights[0] + for col, j in enumerate(sub): + x = Dinv[col] + nz = numpy.flatnonzero(abs(x) > tol) + V[i, j] = reduce(add, (Kd[m] * x[m] for m in nz)) if len(nz) else Zero() + processed.add(i) + def _check_mapping(self, fiat_element): mappings = set(fiat_element.mapping()) if mappings not in ({"contravariant piola"}, {"double contravariant piola"}): @@ -466,17 +615,21 @@ def facet_dof_rows(self, V: numpy.ndarray, group: dict, :arg processed: Indices of the already assembled rows; updated in place. :arg tol: Tolerance for detecting zeros in the numeric coefficients. """ + group = self._divergence_rows(V, group, J, processed) + if not group: + return + if self._is_cartesian_point_group(group): + # Not a genuine facet moment (see _piola_point_rows): e.g. the + # edge dofs of AlfeldSorokina are plain Cartesian point values + # that happen to sit on a codimension-1 entity. + PiolaPhysicallyMappedElement._check_piola_group(group) + self._piola_point_rows(V, group, J, processed, tol) + return PiolaPhysicallyMappedElement._check_piola_group(group) - ref_el = fiat_element.get_reference_element() - sd = ref_el.get_spatial_dimension() - that = ref_el.compute_tangents(sd - 1, entity) - nhat = ref_el.compute_scaled_normal(entity) - Ghat = numpy.column_stack([nhat, *that]) - Ghatinv = numpy.linalg.inv(Ghat) - - Jnp = numpy.array([[J[i, k] for k in range(sd)] for i in range(sd)], - dtype=object) - Knp = adjugate(Jnp).T + sd = J.shape[0] + frame = PiolaFacetFrame(fiat_element, entity, J) + nhat = frame.normal + Y = frame.Y # Reference frame coordinate profiles, shared with the physical nodes: # each node's own quadrature weights, decomposed into (normal, @@ -487,7 +640,7 @@ def facet_dof_rows(self, V: numpy.ndarray, group: dict, for i, ell in group.items(): C = ell.weights.reshape(-1, *(sd,) * ell.rank) for _ in range(ell.rank): - C = numpy.tensordot(C, Ghatinv, axes=(1, 1)) + C = numpy.tensordot(C, frame.Ghatinv, axes=(1, 1)) coords[i] = C.reshape(len(ell.points), -1) # Pure normal moments are Piola invariant if numpy.allclose(coords[i][:, 1:], 0, atol=tol): @@ -499,29 +652,6 @@ def facet_dof_rows(self, V: numpy.ndarray, group: dict, rank, = {ell.rank for ell in group.values()} points, = {ell.points for ell in group.values()} - # Frame coordinates of the mapped frame image of the reference frame: - # the normal is invariant and the pulled tangents are expanded by a - # symbolic solve in the mapped frame [K nhat | J that_k] - Kn = nhat @ Knp.T - Jt = that @ Jnp.T - A = numpy.column_stack([Kn, *Jt]) - - Y = numpy.full((sd, sd), Zero(), dtype=object) - Y[0, 0] = Literal(1.0) - Y[:, 1:] = inverse(A) @ (Knp @ that.T) - - # Physical tangential components are built on the reciprocal basis - # (cross products of the frame), so they carry the in-plane - # contravariant transformation S = adj(G Ghat^{-1})^T of the change - # of tangent Gram matrices. Absorb S^{-1} into the coordinate - # mixing so that the physical profiles keep the reference - # coordinates; in 2D the tangent plane is one-dimensional and S = 1. - G = Jt @ Jt.T - Ghat_t = that @ that.T - Sinv = (numpy.linalg.inv(Ghat_t) @ G) * \ - (numpy.linalg.det(Ghat_t) / determinant(G)) - Y[1:, :] = Sinv @ Y[1:, :] - # Numeric matching of the tangential coordinate profiles in the group. # B has full row rank by unisolvence, so the Gram matrix B @ B.T is # square and invertible; a rank deficiency (a genuine bug) surfaces @@ -609,29 +739,8 @@ def point_dof_rows(self, V: numpy.ndarray, group: dict, :arg processed: Indices of the already assembled rows; updated in place. :arg tol: Tolerance for detecting zeros in the numeric coefficients. """ + group = self._divergence_rows(V, group, J, processed) + if not group: + return PiolaPhysicallyMappedElement._check_piola_group(group) - sd = J.shape[0] - Jnp = numpy.array([[J[i, k] for k in range(sd)] for i in range(sd)], - dtype=object) - K = adjugate(Jnp).T - - subgroups = {} - for i, ell in group.items(): - if len(ell.points) != 1 or ell.rank != 1: - raise NotImplementedError( - "Only single-point vector evaluations are handled.") - subgroups.setdefault(ell.points, {})[i] = ell - - for sub in subgroups.values(): - directions = numpy.array([ell.weights[0] for ell in sub.values()]) - if directions.shape[0] != directions.shape[1]: - raise NotImplementedError( - "Directions do not span the vector components.") - Dinv = numpy.linalg.inv(directions.T) - for i, ell in sub.items(): - Kd = K @ ell.weights[0] - for col, j in enumerate(sub): - x = Dinv[col] - nz = numpy.flatnonzero(abs(x) > tol) - V[i, j] = reduce(add, (Kd[m] * x[m] for m in nz)) if len(nz) else Zero() - processed.add(i) + self._piola_point_rows(V, group, J, processed, tol) diff --git a/zany_claude.md b/zany_claude.md index aed946952..1da850843 100644 --- a/zany_claude.md +++ b/zany_claude.md @@ -235,10 +235,41 @@ discrepancy" in `finat/piola_mapped.py`) emerges automatically from the Vandermo residual elimination, since the per-point normal moments evaluate against vertex basis functions of the extended element. -Next steps: remaining PiolaBubbleElement users (GN second kind, H1div: interior -derivative moments need the divergence detJ rule), ArnoldWinther (vertex tensor values -+ higher facet moments), the extended-element path for reduced HCT (macro polynomial -spaces); covariant elements. +**AlfeldSorokina and GuzmanNeilanH1div (divergence at vertices).** AlfeldSorokina's dual +basis interleaves, at every vertex, one `PointDivergence` node with `sd` `ComponentPointEvaluation` +nodes at the same point; GuzmanNeilanH1div wraps the same AlfeldSorokina dofs (plus GN +facet bubbles), so it needs the identical fix. `PointDivergence` does not fit the +existing `PhysicallyMappedFunctional` shapes: it has both `order = 1` (a derivative) and +`comp != ()` (a value component) on the *same* weight entries, in the pattern $\ell(v) = +\sum_i \partial_i v_i$ — a trace pairing an order-1 derivative multi-index with a rank-1 +value component, not a rank-one `direction` the SVD factorization can recover. Recovered +in `from_fiat` by testing, per point, that the $(alpha, comp)$ weight matrix is a scalar +multiple of the identity; represented with a new `divergence: bool` flag (order and rank +are otherwise meaningless for this node, so reusing them would have made `evaluate`/ +`pullback` silently wrong for it — a new field beats overloading old ones here). Handling +is a one-line closed form, not a generalization of the existing machinery: the +(contravariant) Piola pullback commutes with the divergence up to $\det J$, regardless of +which entity the node sits on, so `PiolaPhysicallyMappedElement._divergence_rows` strips +divergence nodes out of the group first and sets `V[i, i] = detJ * ell.weights[0]` +directly, before either `facet_dof_rows` or `point_dof_rows` runs. + +Stripping the divergence node still left a `facet_dof_rows` bug: AlfeldSorokina's edge +dofs (`dim == sd - 1` in 2D) are `ComponentPointEvaluation`s too — plain Cartesian point +values that happen to sit on a codimension-1 entity, not genuine facet moments. Routing +them through the normal/tangential frame logic built for MTW/JM/GN produced a singular +Gram matrix (`B @ B.T`), because that logic assumes the tangential-only residual group +has exactly `sd - 1` members (the facet completion count), but `sd` unrelated Cartesian +components don't reduce to that shape. Fix: `_is_cartesian_point_group` recognizes a +group of rank-1, order-0 nodes sharing one single point (real facet moments are built +from multi-point quadrature, even at low order — verified by checking `len(ell.points)` +for BernardiRaugel/MTW/JM/GN facet dofs, all $>1$) and dispatches it to the same +`_piola_point_rows` Cartesian-component transform `point_dof_rows` uses, regardless of +the entity's topological dimension. This is the same lesson as the FacetFrame work: +dispatch on what the node's *data* looks like, never on which entity it happens to sit on. + +Next steps: GN second kind (interior derivative moments need the divergence detJ rule, +same as above), ArnoldWinther (vertex tensor values + higher facet moments), the +extended-element path for reduced HCT (macro polynomial spaces); covariant elements. The implementation mirrors the theory factor by factor: From 3da8f96d7440b9696f8c235624255f8940d22d2e Mon Sep 17 00:00:00 2001 From: Pablo Brubeck Date: Thu, 16 Jul 2026 11:41:08 +0100 Subject: [PATCH 23/34] glossary of terms --- finat/zany.py | 92 ++++++++++++++++++++++--- zany_claude.md | 182 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 264 insertions(+), 10 deletions(-) diff --git a/finat/zany.py b/finat/zany.py index 276d80f0f..7051cd5ca 100644 --- a/finat/zany.py +++ b/finat/zany.py @@ -34,6 +34,71 @@ In the language of the theory, the frame expansion in either mixin realizes :math:`E V^c`, and the numeric elimination of the tangential (respectively normal) completion realizes :math:`D`. + +**Glossary.** :math:`E`, :math:`V^c`, and :math:`D` are never materialized as +literal matrices; each is realized implicitly by a piece of the assembly: + +* :math:`V` -- the object array assembled in place by + :meth:`ZanyPhysicallyMappedElement.basis_transformation`; row = reference + node, column = physical node (see ``gem/AGENTS.md``). Starts as the + identity (:func:`~finat.physically_mapped.identity`), which *is* the + :math:`E V^c D` factorization for push-forward-invariant nodes. +* :math:`E` -- realized by keeping only the row(s) a hook actually assembles + (e.g. the normal row in :meth:`ScalarPhysicallyMappedElement.facet_dof_rows`, + discarding the tangential ones) and, for extended/constrained elements + (Bell, Guzman-Neilan), by the final column truncation to + ``self.space_dimension()``. +* :math:`V^c` -- the pure chain-rule/frame content: :class:`FacetFrame` (or + :class:`PiolaFacetFrame`) built from ``J``, giving the diagonal + "own-node" coefficient (``c`` below) and, in the Piola case, the mixing + matrix ``Y``. +* :math:`D` -- the numeric elimination of a completion functional through + *already-assembled* rows of ``V`` (the ``processed`` set), via + :meth:`~finat.functional.PhysicallyMappedFunctional.evaluate`'s + generalized-Vandermonde row (scalar case) or the ``Binv``/``Lmap`` numeric + contractions (Piola case). + +Other recurring symbols, and where the numeric/symbolic boundary falls: + +* ``J`` -- the cell Jacobian, with :math:`x = J\hat x + b` (reference to + physical; see :meth:`~finat.physically_mapped.PhysicalGeometry.jacobian_at`). + This is the *inverse transpose* of the Jacobian in Kirby (2017)'s + convention, whose map :math:`F` goes physical to reference -- a factor + such as a vertex-gradient block of :math:`M` that this module computes as + plain ``J`` is the paper's :math:`J_{\mathrm{paper}}^{-T}`. ``J`` is a + GEM node (indexable, has ``.shape``); :func:`_materialize_jacobian` + produces the numpy object array of GEM scalars that + :func:`~finat.physically_mapped.determinant`/:func:`~finat.physically_mapped.adjugate` + require. +* ``K`` -- the cofactor matrix :math:`\operatorname{adj}(J)^T`, the + contravariant Piola map's action on a (scaled) normal or on a divergence + weight; unrelated to a stiffness matrix despite the common letter. +* ``normal`` -- reference facet normal, but *not* the same scaling in the + two frame classes: :class:`FacetFrame` uses the unit-ish + ``compute_normal`` (its physical image needs rescaling by + ``normal_scale``), while :class:`PiolaFacetFrame` uses the already-scaled + ``compute_scaled_normal`` (its physical image, ``K @ normal``, is used + directly). +* ``direction``, ``weights``, ``order``, ``rank``, ``divergence`` -- the + numeric-or-symbolic fields of a :class:`~finat.functional.PhysicallyMappedFunctional`; + ``direction``/``weights`` are plain numbers as read off the *reference* + FIAT element, and become GEM expressions only after + :meth:`~finat.functional.PhysicallyMappedFunctional.pullback` is applied + (there is no separate type for the two life stages -- track it from + context, as the hooks in this module do). +* ``a``, ``beta`` -- normal/tangential coefficients of a *numeric* direction + in the reference frame (:meth:`FacetFrame.reference_coefficients`). +* ``x`` -- coefficients of a (possibly symbolic) pulled-back direction in + the *physical* frame (:meth:`FacetFrame.decompose`). +* ``c``, ``r`` -- the diagonal own-node coefficient and the tangential + completion residual in :meth:`ScalarPhysicallyMappedElement.facet_dof_rows`. +* ``Dinv`` -- inverse of the numeric direction basis spanning a point-jet or + Cartesian-component group (``point_dof_rows``, ``_piola_point_rows``). +* ``B``, ``Binv``, ``L``, ``Lmap`` -- the tangential-profile Gram system and + the numeric quadrature-point contractions in + :meth:`PiolaPhysicallyMappedElement.facet_dof_rows`; see the "sparsity + gotcha" note there for why the quadrature-point axis must be contracted + numerically before multiplying by any symbolic frame-mixing scalar. """ from abc import abstractmethod @@ -48,6 +113,20 @@ from finat.physically_mapped import PhysicallyMappedElement, adjugate, determinant, identity, inverse +def _materialize_jacobian(J: Node) -> numpy.ndarray: + """Materialize a GEM Jacobian node as a numpy object array of GEM scalars. + + :arg J: GEM expression for the cell Jacobian, shape ``(sd, sd)``. + :returns: A ``(sd, sd)`` numpy object array with the same entries as + ``J``, usable with the numeric-or-symbolic linear algebra of + :mod:`finat.physically_mapped` (:func:`~finat.physically_mapped.determinant`, + :func:`~finat.physically_mapped.adjugate`), which index a GEM node's + entries directly rather than through numpy fancy indexing. + """ + sd = J.shape[0] + return numpy.array([[J[i, k] for k in range(sd)] for i in range(sd)], dtype=object) + + def generalized_cross(tangents) -> numpy.ndarray: r"""Generalized cross product of d-1 vectors in d dimensions. @@ -161,8 +240,7 @@ def __init__(self, fiat_element: FiniteElement, entity: int, J: Node): Ghat = numpy.column_stack([self.normal, *self.tangents]) self.Ghatinv = numpy.linalg.inv(Ghat) - Jnp = numpy.array([[J[i, k] for k in range(sd)] for i in range(sd)], - dtype=object) + Jnp = _materialize_jacobian(J) Knp = adjugate(Jnp).T # Frame coordinates of the mapped frame image of the reference frame: @@ -502,10 +580,7 @@ def _divergence_rows(V: numpy.ndarray, group: dict, J: Node, processed: set) -> """ divs = {i: ell for i, ell in group.items() if ell.divergence} if divs: - sd = J.shape[0] - Jnp = numpy.array([[J[i, k] for k in range(sd)] for i in range(sd)], - dtype=object) - detJ = determinant(Jnp) + detJ = determinant(_materialize_jacobian(J)) for i, ell in divs.items(): V[i, i] = detJ * ell.weights[0] processed.add(i) @@ -549,10 +624,7 @@ def _piola_point_rows(V: numpy.ndarray, group: dict, J: Node, :arg processed: Indices of the already assembled rows; updated in place. :arg tol: Tolerance for detecting zeros in the numeric coefficients. """ - sd = J.shape[0] - Jnp = numpy.array([[J[i, k] for k in range(sd)] for i in range(sd)], - dtype=object) - K = adjugate(Jnp).T + K = adjugate(_materialize_jacobian(J)).T subgroups = {} for i, ell in group.items(): diff --git a/zany_claude.md b/zany_claude.md index 1da850843..f5fa55267 100644 --- a/zany_claude.md +++ b/zany_claude.md @@ -271,6 +271,149 @@ Next steps: GN second kind (interior derivative moments need the divergence detJ same as above), ArnoldWinther (vertex tensor values + higher facet moments), the extended-element path for reduced HCT (macro polynomial spaces); covariant elements. +## Paper process (Kirby, Marsden & Brubeck, "Automation of Finite Element +Transformations"): staged plan + +Process agreed 2026-07-16 (full plan: `~/.claude/plans/lazy-yawning-quasar.md`): alternate +a *math stage* (derive/restate theory, hand-work an example, extract a glossary) with a +*code stage* (rename/refactor to match, verify via `test/finat/test_zany_mapping.py` + +`flake8` + `pydocstyle`), starting with the scalar case (Stages 0-3) and then testing the +hypothesis that Piola-mapped elements unify further by composing the Piola transform with +the scalar machinery (Stages 4-6), before assembling `fiat_zany_auto/paper.tex` (Stage 7). +Math work accumulates here before being promoted into the paper. Stage 0 (restate +pullback/push-forward duality, $M = V^T$, and $V = EV^cD$ precisely) is already satisfied +by the "Transformation Theory" section opening this file. + +### Stage 1 (2026-07-16): worked example — Hermite vertex jets + +The affine-interpolation-equivalent case (`ScalarPhysicallyMappedElement.point_dof_rows`, +`finat/zany.py:420-458`), worked by hand and checked to machine precision against +`finat.Hermite.basis_transformation` on a concrete triangle. + +**Setup.** Reference cell $\hat K$ = UFC triangle, vertex $\hat v_0 = (0,0)$. FIAT's cubic +Hermite dual basis carries, at $\hat v_0$, one value node and two derivative nodes +$\hat\ell_1 = \partial/\partial\hat x_1$, $\hat\ell_2 = \partial/\partial\hat x_2$ +(`FIAT.hermite.CubicHermite(ref).dual_basis()`, confirmed via `deriv_dict`). Physical +triangle vertices `((0.0, 0.1), (1.17, -0.09), (0.15, 1.84))` (the `phys_el` fixture of +`test/finat/conftest.py`), giving the constant Jacobian +$$J = \begin{pmatrix} 1.17 & 0.15 \\ -0.19 & 1.74\end{pmatrix}$$ +(`coordinate_mapping.jacobian_at`, i.e. $x = J\hat x + b$ — this is the *code's* $J$, +mapping reference to physical; see the reconciliation note below for how it relates to +the "paper's $J$" of the opening Transformation Theory section). + +**Mechanism.** Away from a facet there is no geometric frame (`FacetFrame` does not +apply), so `point_dof_rows` treats the whole order-1 group at $\hat v_0$ as its own +completion, exactly the affine-interpolation-equivalent case of Kirby (2017): the group +must span the derivative jet, and $V$'s $2\times2$ block on $\{1,2\}$ is obtained by +expanding each node's *pulled-back* direction in the group's own direction basis. +Precisely (`finat/zany.py:437-458`): + +1. `PhysicallyMappedFunctional.from_fiat` recovers each $\hat\ell_i$'s direction/weight + pair $(d_i, w_i)$ from the reference dual basis by a rank-1 SVD of the derivative + weight matrix — only determined up to a common sign/scale, so $w_i d_i$ (not $d_i$ + alone) is the invariant quantity, the *true* reference direction $e_i$. Here FIAT + stores $\hat\ell_1$ directly as $(d_1,w_1)=((1,0),\,1)$, but recovers $\hat\ell_2$ as + $(d_2,w_2) = ((0,-1),\,-1)$ (sign flipped by the SVD) — with $e_2 = w_2 d_2 = (0,1)$ + the correct Cartesian direction is recovered regardless. +2. `directions = [d_1; d_2]`, `Dinv = inv(directions.T)`: because $d_i$ (not $e_i$) enters + here, $Dinv$ absorbs the same sign ambiguity that $w_i$ will later correct. +3. For each pair $(i,j)$: `s = _weight_ratio(w_i, w_j)` $= w_i/w_j$ recovers the *relative* + sign/scale between the two recovered factorizations (`_weight_ratio` raises if the + weights are not simply proportional — here trivially $\pm1/\pm1$), and + `x = s * Dinv[col_j]`; then `V[i,j] = pullback(J, d_i) @ x`, where + `pullback(J, d_i) = J @ d_i` for an order-1 direction (verified numerically: the chain + rule for a first derivative is a plain linear map, no transpose, since `direction` is + contravariant in the *entity* Jacobian at this stage — the transpose appears only once + $d_i$ is re-expressed in the *dual* frame `Dinv` provides). + +Composing steps 2-3 for the true (sign-corrected) directions $e_i$ collapses to the clean +statement $V[i,j] = e_j^T J\, e_i$ — i.e. on this basis $V\big|_{\{1,2\}} = E^TJE$ with +$E = [e_1\,|\,e_2]$ (here $E=I$, the Cartesian basis, so $V\big|_{\{1,2\}} = J^T$ exactly); +the $w_i/d_i$ split and `_weight_ratio` correction exist only so this holds *regardless* +of which sign/scale `from_fiat`'s SVD happens to recover, not because the mathematics +needs a sign at all. + +**Verification.** Computed by hand from the formula above and checked bit-for-bit against +`finat.Hermite(ref).basis_transformation(mapping)` (evaluated via `gem.interpreter`): +$$ +V\big|_{\{1,2\}} = J^T = \begin{pmatrix} 1.17 & -0.19 \\ 0.15 & 1.74 \end{pmatrix} +\quad\Longleftrightarrow\quad +M\big|_{\{1,2\}} = V^T\big|_{\{1,2\}} = J = \begin{pmatrix} 1.17 & 0.15 \\ -0.19 & 1.74 \end{pmatrix}, +$$ +matching `M[1:3,1:3]` from the running code exactly (both diagonal *and* off-diagonal +entries, so the sign/scale bookkeeping above is exercised nontrivially, not just checked +on a symmetric or diagonal case). + +**Reconciliation with the opening Transformation Theory section.** Line 15 there states +the vertex-gradient block of $M$ as "$J^{-T}$ ... in the *paper's* $J$." The code's +`coordinate_mapping.jacobian_at` returns $J_{code}$ with $x = J_{code}\hat x + b$ +(reference $\to$ physical), whereas Kirby (2017)'s $F$ maps physical $\to$ reference, so +its Jacobian is $J_{paper} = J_{code}^{-1}$; hence $J_{paper}^{-T} = J_{code}^T$, exactly +the $M$ block computed above. **Glossary note for Stage 3**: every code docstring should +state explicitly which of these two (inverse, transpose-of-each-other) conventions a +given $J$ is, since the two papers' $F$ and the code's `coordinate_mapping` point in +opposite directions. + +### Stage 2 (2026-07-16): worked example — Morley edge completion + +The general (non-equivalent) completion case (`ScalarPhysicallyMappedElement.facet_dof_rows`, +`finat/zany.py:368-418`, using `FacetFrame`), worked by hand for edge 0 (the hypotenuse, +joining vertices 1 and 2) on the same physical triangle as Stage 1, and checked bit-for-bit +against `finat.Morley`. + +**Setup.** FIAT's Morley dual basis has, on edge 0, a single node $\hat\ell_3$: the +normal derivative at the midpoint $(0.5,0.5)$, stored as an *average* +(`deriv_dict` weights $(1,1)/\sqrt2$ on $(\partial_{\hat x_1}, \partial_{\hat x_2})$, +i.e. direction $\hat n = (1,1)/\sqrt2$, the reference normal itself — Morley has no +separate tangential-derivative node, so $\hat\ell_3$ must be *completed* using the +vertex value nodes ($\hat\ell_1,\hat\ell_2$ at $v_1=(1,0)$, $v_2=(0,1)$, already +processed since $\mathrm{order}=0$) before it can be expanded on the physical cell. +Reference edge-0 tangent (FIAT) $\hat t = (-1,1)$ (the chord $v_2-v_1$, unnormalized). + +**Mechanism.** `FacetFrame(Mo, 0, J)` builds the reference frame +$[\hat n\,|\,\hat t\,]$ and its physical image $[C\,|\,J\hat t\,]$ with +$C = $ `generalized_cross`$(J\hat t)$ (here just a $90°$ rotation of $J\hat t$ in 2D). +`facet_dof_rows` then, for $\hat\ell_3$: + +1. `reference_coefficients(\hat\ell_3.direction)` solves $\hat d = a\hat n + \beta\hat t$ + for $(a,\beta)$ in the *reference* frame — here $a=-1$ (sign from the SVD recovery + of `from_fiat`, harmless: every downstream quantity built from $a$ is consistently + rescaled), $\beta=0$ exactly, since $\hat\ell_3$'s direction *is* the reference normal + with no tangential component by construction. +2. `decompose(\hat\ell_3.pullback(J).direction)` solves the *physical*, symbolic system + $J\hat d = x_0 C + x_1 J\hat t$ via `adjugate`/`determinant` (Cramer's rule, since $J$ + is symbolic in general; numeric here because $J$ is the constant matrix of Stage 1) + giving $(x_0,x_1) = (0.612629,\,-0.244111)$. +3. $c = x_0\cdot(\|C\|/\kappa)/a = 1.337343$ becomes $V[3,3]$ — the diagonal + "own-node" coefficient, i.e. the $B_{nn}$ entry of the notebook's $2\times2$ block + $B = \hat G J^{-T} G^T$ (line 34 of the Transformation Theory section above; that + block's off-diagonal $B_{nt}$ entry is exactly $-c\beta/a$-type bookkeeping, here + $0$ since $\beta=0$). +4. The tangential residual $r = x_1 - c\beta = -0.244111$ is *not itself* a new + unknown: because $\hat t$ is a **mapped reference tangent**, the reference + functional "derivative along $\hat t$ at the midpoint" coincides with a functional + already expressible in the element's own reference basis — `ell.with_direction(\hat t) + .evaluate(Mo)` (a numeric generalized-Vandermonde row) gives coefficients + $(0,1,-1,0,0,0)$: the classical fact that for a quadratic, the derivative along the + full chord at the midpoint equals $f(v_2)-f(v_1)$ exactly (Brubeck & Kirby 2025's + univariate-exactness/FTC argument realizing $D$, here in its simplest 1-node form). +5. Elimination: $V[3,1] \mathrel{+}= r\cdot1 = -0.244111$, $V[3,2] \mathrel{+}= r\cdot(-1) + = +0.244111$ — both already-`processed` vertex rows, so no `NotImplementedError`. + +**Verification.** `finat.Morley(ref).basis_transformation(mapping)` (same physical +triangle as Stage 1) gives column 3 of $M=V^T$ as +$M[:,3] = (0,\,-0.244111,\,0.244111,\,1.337343,\,0,\,0)$, matching $V[3,\cdot]$ from +steps 3-5 to 6 decimal places. + +**What this example newly exercises, vs. Stage 1.** Stage 1's Cartesian point-jet +group needed only $E=I$ (no frame) and a diagonal-plus-off-diagonal $V^c$ block coming +purely from the chain rule. Here, for the first time, $E$ (the frame's normal/tangential +extraction, keeping only the normal row) and $D$ (the numeric elimination of the +tangential residual through *already-assembled, lower-dimension* rows — vertex nodes, +processed before edges by the increasing-dimension loop of `basis_transformation`) are +both genuinely nontrivial and distinct, which is exactly the completion mechanism the +notebook's $V=EV^cD$ factorization (lines 21-36) describes in the abstract. + The implementation mirrors the theory factor by factor: 1. **Sparsity from topology**: each row of $V$ is a reference node; its nonzero columns @@ -288,3 +431,42 @@ The implementation mirrors the theory factor by factor: generalized Vandermonde matrix. This is where GEM-based dual evaluation comes in. 4. Constrained spaces (reduced HCT, Bell) reuse the same machinery on the extended element; the FIAT element must expose the constraint functionals as extra dofs. + +### Stage 3 (2026-07-16): scalar glossary + code refactor — done + +Deliverables, applied to `finat/zany.py` only (zero semantic diff, no behavior change): + +* A module-level glossary docstring mapping every recurring symbol (`V`, `E`, `V^c`, `D`, + `J`, `K`, `normal`, `direction`/`weights`/`order`/`rank`/`divergence`, `a`, `beta`, `x`, + `c`, `r`, `Dinv`, `B`/`Binv`/`L`/`Lmap`) to its code location and mathematical role, and + stating explicitly which Jacobian-inverse convention `J` uses relative to Kirby (2017)'s + $F$ (the Stage 1 finding). +* `_materialize_jacobian(J)`: a new free function replacing three copies of the same + `numpy.array([[J[i, k] ...` snippet (`PiolaFacetFrame.__init__`, `_divergence_rows`, + `_piola_point_rows`), so the numpy-materialized Jacobian has one name and one + construction site instead of three silently-identical ones. +* `FacetFrame.normal`/`PiolaFacetFrame.normal`'s differing scaling convention + (`compute_normal` vs `compute_scaled_normal`) is now stated in the module glossary, + not just each class's own docstring, so it's visible without reading both classes. + +Verified: `pytest test/finat/test_zany_mapping.py test/finat/test_zany_automation.py` +(124 passed), the full `test/finat/` suite (338 passed, 8 skipped), `flake8 finat/zany.py`, +`pydocstyle finat/zany.py` — all clean, no matrix entries changed. + +This closes the first math-code loop (Stages 0-3, scalar case). Stages 4+ (Piola +composition hypothesis and beyond) remain only roughly sketched in the plan file and will +be planned in detail next. + +### Paper migration (2026-07-16) + +The plan now lives at `~/git/fiat_zany_auto/PLAN.md`, and the scalar theory +(Stages 0-3 above) has been drafted as Section 2 of `~/git/fiat_zany_auto/paper.tex` +("Automation of finite element transformations", Kirby, Marsden & Brubeck; acmart, +compiles cleanly with `make`). Key notation choices made in the draft, to keep +consistent going forward: $F: K \to \hat K$ physical-to-reference (Kirby 2017), but $J$ +defined by $F^{-1}(\hat x) = J\hat x + b$ (the *code's* Jacobian, stated explicitly in +eq. 2.3); the generic node $\ell_{X,w,d}$; the push-forward closed form +$F_*(\ell_{X,w,d}) = \hat\ell_{\hat X, w, (J^{-1})^{\otimes m}d}$; the "design equation" +$J^{\otimes m}\hat d = \sum_j c_j d_j$ as the unifying statement of every row solve; and +a theory-to-code correspondence table (Table 1). The Piola/implementation/examples +sections are `\todo` stubs pending Stages 4+. From 9026c4748c8dc3609eb0fd8d439e1ac1c5fc6305 Mon Sep 17 00:00:00 2001 From: "Robert C. Kirby" Date: Thu, 16 Jul 2026 17:35:51 -0500 Subject: [PATCH 24/34] Remarks so far --- rck_remarks_for_claude.md | 41 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 rck_remarks_for_claude.md diff --git a/rck_remarks_for_claude.md b/rck_remarks_for_claude.md new file mode 100644 index 000000000..32610f16a --- /dev/null +++ b/rck_remarks_for_claude.md @@ -0,0 +1,41 @@ +We shouldn't assume that det(J) > 0. The UFC ordering we are explicitly utilizing means that we will have both signs. Even in 2d, some triangles will be ordered counterclockwise and others clockwise. This appears in a few places in the document. Note that FIAT's reference normals may be inward or outward pointing (based on UFC convention) and that the existing zany maps in FInAT work without this assumption. + +We need to define "facet frame" explicitly. + + + +Every functional in (2.8) has exactly one direction and order of differentiation. This is probably sufficient for all the nodes we're interested in, but we may find a situation where this needs to be generalized. Don't generalize yet just to do so. + +After (2.9), we say "dividing by J". Change that to multiplying by J^{-T}. + +Be more clear in this paragraph -- push-forward changes the direction and so the actual transformation is geometrically dependent, even if we are doing a lot of preprocessing that can be done numerically. + +Be more clear in the "The design equation" -- we say "conversely", but what are we contrasting with. + +Be more clear about (2.10) -- it's "solvable", but are we going to do that numerically for coefficients of the directions or do we have to do symbolic processing (this will be clear later, but we need to cue the reader as to what's coming) + +Is the direction tensor d always going to be symmetric, and if it's outer(n, n) (say) in 2d, then it has rank 1 instead of 2. Is this reduced rank something that generalizes to other situations (e.g. Wu-Xu) and does it give us anything important to consider? + +Above (2.11) -- "scaled tangent" is FIAT-internal lingo (unit vector times length/measure). Worth defining explicitly. + +Above (2.11) point #3 -- I presume this includes integral moments of derivatives as well? + +Another point in this discussion -- if we're doing things numerically, without dispatching on type, how do we know that we have a normal component.  I think the point is that we don't know and don't need to.  Instead, we're giving suitable bases in reference and physical configurations in which to expand whatever direction we're working with. + +Notation: using big N for the physical normal and little \hat{n} for reference seems odd. Of course, we count things n. Maybe using \nu and \hat{\nu} for physical/reference normals so we don't risk confusion between N and the set of nodes \mathcal{N}? + + +For facet nodes and completion, does this work for derivative nodes that are linear combinations of point derivatives (e.g. integral moments)? +In that case, do we reproduce the fundamental theorem of calculus, writing the completed derivative as the difference between point values on the edges? +This is claimed after (2.21), but a proof that we get FTOC would be nice. + +Is there a nice analog of some Stokes-like theorem for an integral moment of a normal derivative on a face of a tetrahedron? I think there is something like this in Xu's papers on generalized nonconforming elements. Again, it would be nice to show (a theorem?) that we get that from our numerics in the special case. + + +I also think the recursive reduction to form the V matrix product without E V^c D being all explicit is very nice. However, I think it's worth separating the +presentation into two parts: +1) exactly describing how we can form the three bits E V^c and D separately -- V^c essentially follows from the affine-interpolation equivalent discussion, and D is described by the numerical systems we solve, for example. This could be more explicit. +2) then keep describing the reduction via row recursion as a separate step. It's got to be morally equivalent to some graph algorithm for a sparse triple product, but with blocks based on cell topology somehow. + +(2.23) again may have to deal with the possibility that these matrices have negative determinant? + From d85cbe958dfddc6ffb42316794bf8191425acbe2 Mon Sep 17 00:00:00 2001 From: "Robert C. Kirby" Date: Fri, 17 Jul 2026 10:27:14 -0500 Subject: [PATCH 25/34] update claude notes --- rck_remarks_for_claude.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/rck_remarks_for_claude.md b/rck_remarks_for_claude.md index 32610f16a..5ec2afd02 100644 --- a/rck_remarks_for_claude.md +++ b/rck_remarks_for_claude.md @@ -29,8 +29,12 @@ For facet nodes and completion, does this work for derivative nodes that are lin In that case, do we reproduce the fundamental theorem of calculus, writing the completed derivative as the difference between point values on the edges? This is claimed after (2.21), but a proof that we get FTOC would be nice. + Is there a nice analog of some Stokes-like theorem for an integral moment of a normal derivative on a face of a tetrahedron? I think there is something like this in Xu's papers on generalized nonconforming elements. Again, it would be nice to show (a theorem?) that we get that from our numerics in the special case. +Is it necessary at all to "bin" nodes that have the same set of points and/or weights, or does this sort of fall out numerically? + + I also think the recursive reduction to form the V matrix product without E V^c D being all explicit is very nice. However, I think it's worth separating the presentation into two parts: @@ -39,3 +43,11 @@ presentation into two parts: (2.23) again may have to deal with the possibility that these matrices have negative determinant? +Phrase the recursion in pseudocode (algorithmic in latex) + +It would be nice to work out (explicitly with formulas?) the Morley and maybe hexic Argryis to demonstrate the process. +Then, we should put in an example of an element whose transformation is *not* explicitly known in the literature (e.g. one of Xu's generalized nonconforming elements?) + + +Stylistic point: is it worth identifying propositions/theorems about the calcultaions we do to, say, identify the structure/entries of V^c, E, D, and the recursive algorithm for the product? + From c46d3fef79fc8ac2ecf6c39e182d41aa1a6fe042 Mon Sep 17 00:00:00 2001 From: Pablo Brubeck Date: Fri, 17 Jul 2026 17:05:34 +0100 Subject: [PATCH 26/34] response --- zany_claude.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/zany_claude.md b/zany_claude.md index f5fa55267..64a3708fb 100644 --- a/zany_claude.md +++ b/zany_claude.md @@ -470,3 +470,53 @@ $F_*(\ell_{X,w,d}) = \hat\ell_{\hat X, w, (J^{-1})^{\otimes m}d}$; the "design e $J^{\otimes m}\hat d = \sum_j c_j d_j$ as the unifying statement of every row solve; and a theory-to-code correspondence table (Table 1). The Piola/implementation/examples sections are `\todo` stubs pending Stages 4+. + +### Review response, RCK round 1 (2026-07-17) + +Robert's remarks (`rck_remarks_for_claude.md`) addressed in `~/git/fiat_zany_auto/paper.tex` +(now 12 pp., compiles clean). Disposition: + +- **det J > 0 dropped everywhere.** §2.1 now states both orientations occur under UFC; + the normal formula (2.14) is presented as orientation-covariant (κ = ±1, ν flips with + C exactly as the reference formula would on a reflected cell); (2.23) notes Gram + determinants are positive and det J enters linearly. *Verified in code*: ran + `check_zany_mapping` on a clockwise triangle (det J = −2.06) for Morley, Hermite, + Bell, Argyris(5,6,7, avg=True), WuXuH3NC, WuXuRobustH3NC, and on a reflected tet + (det J = −1.28) for Morley/Hermite — all pass. **Note: the test-suite fixtures only + use positively oriented cells; suggest adding a flipped-cell parametrization.** +- **Notation**: physical/reference normals renamed N, n̂ → ν, ν̂ (avoids clash with + nodes n̂_j and node-set 𝒩). Facet frame and scaled tangents (t̂_k = v̂_k − v̂_0) + now defined explicitly (new eqs. framedef/scaledtangents). +- **(2.8) scope**: one direction + one order per functional stated as deliberate, + no premature generalization; symmetry-WLOG + rank-deficiency remark (d = ν⊗ν rank 1; + construction never uses rank; covers Wu–Xu-type nodes). +- **"dividing by J"** → multiplying each slot by J^{-1} (gradients by J^{-T}); + invariance/design-equation paragraphs rewritten ("conversely" contrast made explicit; + numeric-vs-symbolic split cued early); no-type-dispatch point added ("a normal + component is detected through a ≠ 0, not declared through a class hierarchy"). +- **Binning**: §2.4 now states grouping = entity (element data) + order (numeric); + shared points/weight-ratios/direction-basis are checked numerically, nothing else needed. +- **FTOC proof**: new Prop. 2.1 (exactness identities are recovered — unisolvence + uniqueness, 1-line proof), with FTOC instance proved (average → all C¹; Morley + midpoint → on P̂ via linearity of ∂_t f along the edge) and IBP/Stokes-type (Wu–Xu + face moments) as further instances. +- **E, V^c, D separated then recursion (his 2-part request)**: §2.6 now defines the + three factors exactly (V^c block per eq:Vcblock, E Boolean, D rows = w·V — self- + referential but triangular by entity structure), §2.7 "Row recursion" evaluates the + product without materializing (graph/topological-order remark included), with + **Algorithm 1** (algpseudocode) mirroring `basis_transformation`/`facet_dof_rows`. +- **Worked examples**: explicit 6×6 Morley V with closed forms c_e = det J |t̂|/|Jt̂|, + r_e = (Jν̂·Jt̂)/|Jt̂|² — *verified to 6.7e-16 against the code on both orientations*; + hexic Argyris completion rows computed: ℓ̂⁽⁰⁾ = δ_{v+} − δ_{v−} (FTOC) and + ℓ̂⁽¹⁾ = 3δ_{v+} + 3δ_{v−} − μ̂_ê (IBP, couples to same-edge trace moment; exact + integers to machine precision). +- **Novel-element example**: sec:examples todo now points at WuXuH3NC/WuXuRobustH3NC, + which the automation already handles (tested above). +- **Honesty fix**: scalar completions only ever couple to *identity* rows; the fully + recursive regime (coupling into non-identity rows) is now correctly attributed to the + Piola macroelements (§3, deferred). +- New bib entries: `ufc` (Alnæs–Logg–Mardal FEniCS-book chapter), `wu-xu` (Math. Comp. 2019). + +Not done (open for co-authors): whether more calculations deserve proposition status +(only Prop 2.1 added so far); explicit Stokes-identity theorem for tet face moments +(covered as an instance of Prop 2.1, no standalone theorem); India's email still TODO. From cb0accf55891b18fe734372654bb062641798c70 Mon Sep 17 00:00:00 2001 From: Pablo Brubeck Date: Fri, 17 Jul 2026 18:28:40 +0100 Subject: [PATCH 27/34] update live notebook --- finat/wuxu.py | 5 +- zany_claude.md | 148 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 2 deletions(-) diff --git a/finat/wuxu.py b/finat/wuxu.py index 395015661..7e1ecacb4 100644 --- a/finat/wuxu.py +++ b/finat/wuxu.py @@ -1,5 +1,6 @@ from finat.fiat_elements import ScalarFiatElement from finat.physically_mapped import identity, PhysicallyMappedElement +from finat.zany import ScalarPhysicallyMappedElement from finat.argyris import _vertex_transform from finat.citations import cite from gem import ListTensor @@ -8,7 +9,7 @@ import numpy -class WuXuRobustH3NC(PhysicallyMappedElement, ScalarFiatElement): +class WuXuRobustH3NC(ScalarPhysicallyMappedElement, ScalarFiatElement): def __init__(self, cell, degree=7): if degree != 7: raise ValueError("Degree must be 7 for robust Wu-Xu element") @@ -19,7 +20,7 @@ def basis_transformation(self, coordinate_mapping): return wuxu_transformation(self, coordinate_mapping) -class WuXuH3NC(PhysicallyMappedElement, ScalarFiatElement): +class WuXuH3NC(ScalarPhysicallyMappedElement, ScalarFiatElement): def __init__(self, cell, degree=4): if degree != 4: raise ValueError("Degree must be 4 for the Wu-Xu element") diff --git a/zany_claude.md b/zany_claude.md index 64a3708fb..c76a3f2ff 100644 --- a/zany_claude.md +++ b/zany_claude.md @@ -520,3 +520,151 @@ Robert's remarks (`rck_remarks_for_claude.md`) addressed in `~/git/fiat_zany_aut Not done (open for co-authors): whether more calculations deserve proposition status (only Prop 2.1 added so far); explicit Stokes-identity theorem for tet face moments (covered as an instance of Prop 2.1, no standalone theorem); India's email still TODO. + +### Stage 4 (2026-07-17): Piola theory derived as a composition with the scalar machinery + +Goal (user hypothesis from PLAN.md): derive the Piola-mapped case *from* the scalar +case by composing basis transformations, as the route to unifying +`ScalarPhysicallyMappedElement` and `PiolaPhysicallyMappedElement`. Result: the +hypothesis is correct, the composition is exact, and it was verified to machine +precision against every Piola element in the FInAT test zoo (automated *and* +hand-coded), in 2D and 3D, on both orientations. Details below; conventions as in +the paper (`~/git/fiat_zany_auto/paper.tex` §2): $F : K \to \hat K$, +$F^{-1}(\hat x) = J\hat x + b$. + +**1. Composition theorem.** On an affine cell every Piola pullback factors through +the componentwise scalar pullback $F^*_0(\hat f) = \hat f \circ F$: +$$F^*_{\mathrm{piola}} = \theta_A \circ F^*_0, \qquad (\theta_A f)(x) = A\,f(x),$$ +with $A$ a *constant* matrix acting on the value components: $A = J/\det J$ +(contravariant), $A = J^{-T}$ (covariant), slot-wise for double variants. +Dually, on functionals, +$$F_*^{\mathrm{piola}} = F_*^0 \circ \theta_A^*, \qquad \theta_A^*(n) = n \circ \theta_A,$$ +and both factors act *slot-locally* on the generic node: extend the scalar node to +$\ell_{X,W}(f) = \sum_q \langle W_q, \nabla^m f(x_q)\rangle$ where $W_q$ now carries +$r$ value slots and $m$ derivative slots. Then the unified push-forward is +$$F_*(\ell_{X,W}) = \hat\ell_{\hat X, \hat W}, \qquad +\hat W = (\text{each contravariant value slot}) \cdot \Theta^T + \;\otimes\; (\text{each covariant value slot}) \cdot J^{-1} + \;\otimes\; (\text{each derivative slot}) \cdot J^{-1},$$ +with $\Theta^T = J^T/\det J = K^{-1}$, $K = \det J \, J^{-T}$ the cofactor matrix. +Points and scalar weights are preserved (paper eq. 2.9 is the $r=0$ case). Note the +collapse: **covariant value slots transform exactly like derivative slots** — a +covariant element is "scalar theory with one more $J^{-1}$ slot". + +**2. Mirror duality of invariance.** A physical slot direction $\delta$ is invariant +iff $\delta = \rho^{-1}\hat\delta$ for the slot map $\rho$: +- derivative / covariant slots: $\delta = J\hat\delta$ — mapped tangents (scalar case, + paper §2.2); +- contravariant slots: $\delta = K\hat\delta$ — cofactor images. + +The geometric content is one lemma used by both cases: the generalized cross +product intertwines $J$ and $K$, $\bigwedge_k J\hat t_k = K \bigwedge_k \hat t_k$ +(i.e. $C = K\hat C$). In the scalar case this gave the physical unit normal +$\nu = \kappa C/|C|$; in the Piola case it gives the **cofactor lemma**: FIAT's +`compute_scaled_normal` is exactly $K$-covariant, +$$K \hat\nu^s = \nu^s_{\mathrm{phys}} \quad \text{(verified: error} \le 3\cdot10^{-16}, +\text{all facets, 2D+3D, both orientations)},$$ +so physical normal-flux moments are invariant *with no symbolic factor at all* +(the mirror of the scalar case's invariant mapped-tangential derivatives — but +exact, no $\kappa$ needed). Consequence: FIAT's physical scaled normal is NOT +always outward on negatively oriented cells; its sign rides the UFC vertex +ordering, which is what makes the invariance orientation-robust. + +**3. Facet frame, mirrored, with closed-form net.** Reference frame +$[\hat\nu^s \,|\, \hat t_k]$ (scaled normal + scaled tangents). FIAT's physical +facet-dof conventions (read off `FIAT/mardal_tai_winther.py` and verified across +the zoo): +- normal profiles: against $\nu^s = K\hat\nu^s$ → invariant; +- tangential profiles, 2D: against plain mapped tangents $J\hat t$; +- tangential profiles, 3D: against $\nu^s \times (J\hat b)$, cross products of the + scaled normal with mapped in-plane (RT-profile) vectors — *same formula on + reference and physical cells*, the same principle as the scalar normal. + +Pushing the 3D convention forward with the slot calculus, using the identity +$\Theta^T(Ka \times Jb) = (J^{-1}Ka)\times b$ and BAC-CAB, the net map on frame +coordinates is +$$N = \begin{pmatrix} 1 & -\det J\, \frac{\hat t_l \cdot M^{-1}\hat\nu^s}{|\hat\nu^s|^2} \\ 0 & s\,I_{d-1} \end{pmatrix}, +\qquad s = \det J\, \frac{\hat\nu^s \cdot M^{-1} \hat\nu^s}{|\hat\nu^s|^2}, +\qquad M = J^T J .$$ +The tangential block is a **scalar multiple of the identity** — the +reciprocal-basis correction $S^{-1}$ and the mixing matrix $Y$ of +`PiolaFacetFrame` are derived consequences of this, not independent structure; +no in-plane matrix correction is needed. The top-right entries are the +completion residuals (the mirror of the scalar $r_k$): a pulled-back tangential +profile leaves behind a *normal*-direction functional, eliminated numerically +through the generalized Vandermonde row (`evaluate` on the nodal basis), exactly +as the scalar case eliminates *tangential* residuals. Spot-verified numbers +(fixture cells): MTW 3D face 0: $s = 1.730944$, completion row +$(0.006240, -0.020316)$, matching an unconstrained least-squares fit of FIAT's +actual physical duals to 9e-16; GN2 2D edge 0 tangential dof: pushed frame +coords $(0.398465, 1.154215)$ = closed form. + +**4. Divergence nodes.** $\mathrm{div}$ = contraction of one contravariant value +slot with one derivative slot; the slot maps contract to +$(K^{-1})(J^{-1})^T \delta = \delta/\det J$, so +$F_*(\ell_{\mathrm{div}}) = (\det J)^{-1} \hat\ell_{\mathrm{div}}$ — invariant up +to the scalar $\det J$, independent of entity ("the divergence miracle", = +`_divergence_rows`). Tensor case (moments of $\mathrm{div}\,\sigma$ against +vector weights, the Arnold-Winther constraint nodes): one value slot survives +and maps by $\Theta^T$, same $1/\det J$; covered by the calculus, currently +blocked only by `from_fiat` not parsing `IntegralMomentOfTensorDivergence`. + +**5. Point data vs moments.** Single-point order-0 nodes (vertex values, edge +midpoint values, interior point values — C0-type data) keep Cartesian components; +their push-forward is the plain $\Theta^T$ slot map and the group block-solve +gives $K$-blocks per slot (the exact mirror of the scalar vertex jets, whose +blocks are $J^T$ per slot). Interior *moments* are invariant by convention +(physical tests are Piola-mapped). **Bug-relevant discovery**: the current +`PiolaPhysicallyMappedElement.invariant_dofs` rule (`order == 0 and dim == sd`) +misclassifies interior Cartesian *point* data as invariant — it happens to be +correct for every currently automated element, but is wrong for Guzman-Neilan +second kind (interior barycenter values → need $K$ blocks, as its hand-coded +transformation does) and Hu-Zhang point variant (interior point values of +$\sigma$). Discriminator that works for the whole zoo: single-point order-0 +nodes are point data; multi-point are moments. (HZ-point facet dofs additionally +show rank-2 single-point *frame* dofs, so the facet-level Cartesian test +`len(points)==1 and rank==1` is still needed there.) + +**6. Unified formulation = duality.** Everything above collapses into one +statement: with slot-mapped weights, the generalized Vandermonde matrix +$$B_{ij} = F_*(n_i)(\hat\psi_j)$$ +is computable for any pullback that factors through $\theta_A$, and $V = B^{-1}$, +inverted blockwise along the entity hierarchy — the row recursion of paper +§2.7 is precisely this block-triangular inversion. A ~60-line prototype +(numeric $J$; slot maps + frame net + divergence + point-data rules, nothing +else) reproduces `basis_transformation` to machine precision for: +MTW(1,2), JohnsonMercier, ArnoldWintherNC, AlfeldSorokina, BernardiRaugel(+Bubble), +GuzmanNeilan first kind (1,2), **GuzmanNeilan second kind**, GN Bubble, GN H1div, +ReducedArnoldQin, ChristiansenHu, **HuZhang 3/4 in both point and integral +variants** — 2D and 3D where defined, positive and negative orientation +(bold = elements the current automated class cannot handle; their hand-coded +transformations agree with the composition theory). All `check_zany_mapping` +ground-truth tests also pass on reflected cells (fixtures only test positive +orientation; worth adding). + +**7. What this means for Stage 5 (code unification).** The +`{Scalar|Piola}PhysicallyMappedElement` split reduces to a small table keyed by +the FIAT mapping string: + +| ingredient | affine (scalar) | contravariant piola | covariant piola | +|-----------------------------|-----------------------------|------------------------------|-----------------| +| value slot map | $I$ | $\Theta^T = K^{-1}$ | $J^{-1}$ | +| derivative slot map | $J^{-1}$ | $J^{-1}$ | $J^{-1}$ | +| frame-stable directions | tangents ($J\hat t$) | scaled normal ($K\hat\nu^s$) | tangents | +| completed directions | unit normal $\nu$ | in-plane (2D: $J\hat t$; 3D: $\nu^s{\times}J\hat b$) | normal | +| point-data block | $J^T$ per slot | $K$ per slot | $J^T$ per slot | +| special contractions | — | div → $\det J$ | curl analog | + +plus one shared engine: recover node data numerically (`from_fiat` extended with +value slots — already done), decide invariance from frame profiles, expand the +non-stable part symbolically, eliminate residuals through `evaluate`, recurse +over entities. `FacetFrame`/`PiolaFacetFrame` merge into one class parametrized +by the stable subspace; the $S^{-1}$/`Y` machinery is replaced by the closed-form +$N$ above. Newly automatable for free: GN2, HuZhang (both variants), BR, CH, RAQ, +and AW/AWnc once `from_fiat` learns tensor-divergence moments. Covariant (Nedelec +-type zany) elements: theory ready, value slot $\equiv$ derivative slot. + +Verification artifacts: prototype scripts run in-session 2026-07-17 (see git log +of this file for the summary; the prototype is small enough to re-derive from +items 1-6, and Stage 5 will turn it into the production implementation with +symbolic $J$). From b50562a89f9283f95b68960d7000f2a221139676 Mon Sep 17 00:00:00 2001 From: Pablo Brubeck Date: Fri, 17 Jul 2026 18:45:49 +0100 Subject: [PATCH 28/34] Piola prototype --- test/finat/test_claude_zany_piola.py | 210 +++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 test/finat/test_claude_zany_piola.py diff --git a/test/finat/test_claude_zany_piola.py b/test/finat/test_claude_zany_piola.py new file mode 100644 index 000000000..3ae855880 --- /dev/null +++ b/test/finat/test_claude_zany_piola.py @@ -0,0 +1,210 @@ +r"""Numeric prototype of the unified transformation theory for Piola elements. + +On an affine cell, every Piola pullback factors through the componentwise +affine pullback: :math:`F^*_{piola} = \theta_A \circ F^*_{affine}`, with +:math:`\theta_A` pointwise multiplication of the value components by a +constant matrix. Dually, the push-forward of a degree of freedom acts +slot-locally on its weight tensor: each contravariant value slot is +contracted with :math:`\Theta^T = J^T/\det J = K^{-1}` (:math:`K` the +cofactor matrix of :math:`J`), and each derivative slot with :math:`J^{-1}`, +exactly as in the scalar theory. The matrix :math:`V` relating reference +nodes to push-forwards of physical nodes is then obtained by duality alone: + +.. math:: B_{ij} = F_*(n_i)(\hat\psi_j), \qquad V = B^{-1}, + +where the physical node :math:`n_i` is defined from the reference node by +the FIAT frame conventions: + +* facet moments: normal profiles against the physical scaled normal + :math:`K\hat\nu^s` (invariant, by the exact covariance of + ``compute_scaled_normal``), tangential profiles against the mapped + tangents :math:`J\hat{t}` in 2D, and against cross products + :math:`\nu^s \times J\hat{b}` of the scaled normal with mapped in-plane + vectors in 3D, whose push-forward has the closed form implemented in + :func:`facet_frame_net` (a *scalar* tangential block -- the ``Y`` mixing + matrix and ``Sinv`` reciprocal-basis correction of + :class:`finat.zany.PiolaFacetFrame` are consequences of this formula); +* single-point evaluations (vertex, edge, or interior point data): Cartesian + components kept, so the slot map is plain :math:`\Theta^T`; +* interior moments: invariant by convention (physical test functions are + Piola-mapped); +* divergence nodes: the value and derivative slot maps contract to + :math:`\delta/\det J`, so the row is :math:`\det J` times the identity. + +This module asserts that :math:`B^{-1}` agrees with +``basis_transformation`` to machine precision for the Piola element zoo, +in 2D and 3D, on cells of both orientations -- including hand-coded +elements not yet handled by :class:`finat.zany.PiolaPhysicallyMappedElement` +(Guzman-Neilan second kind, Hu-Zhang). See ``zany_claude.md`` (Stage 4) +for the derivation. +""" + +import numpy as np +import pytest + +import FIAT +import finat +from FIAT.reference_element import make_affine_mapping, ufc_simplex +from finat.functional import PhysicallyMappedFunctional +from gem.interpreter import evaluate + +from .conftest import MyMapping + + +def evaluate_divergence(ell, fiat_element): + """Apply a divergence functional to the nodal basis of a FIAT element. + + :arg ell: A divergence :class:`PhysicallyMappedFunctional`. + :arg fiat_element: The FIAT element providing the nodal basis. + :returns: The vector of values of the functional on the nodal basis. + """ + sd = fiat_element.get_reference_element().get_spatial_dimension() + tab = fiat_element.tabulate(1, ell.points) + alphas = [tuple(int(k == c) for k in range(sd)) for c in range(sd)] + div = sum(tab[alphas[c]].reshape(tab[alphas[c]].shape[0], sd, -1)[:, c, :] + for c in range(sd)) + return div @ ell.weights + + +def facet_frame_net(ref_el, entity, J): + r"""Net Cartesian map taking a facet node's reference weights to the + weights of its pushed-forward physical partner. + + In frame coordinates :math:`[\hat\nu^s | \hat{t}_l]` the map is + + .. math:: + N = \begin{pmatrix} 1 & -\det J\, \hat{t}_l \cdot M^{-1}\hat\nu^s + / |\hat\nu^s|^2 \\ + 0 & s I \end{pmatrix}, + \qquad + s = \det J\, \frac{\hat\nu^s \cdot M^{-1}\hat\nu^s}{|\hat\nu^s|^2}, + \qquad M = J^T J, + + for the 3D cross-product tangential convention; in 2D the tangential + directions are the plain mapped tangents. The top-right entries are the + completion residuals: a pulled-back tangential profile leaves behind a + normal-direction functional, eliminated through the generalized + Vandermonde row exactly as in the scalar theory. + + :arg ref_el: The reference cell. + :arg entity: The facet number. + :arg J: The (numeric) cell Jacobian. + :returns: The net map as an (sd, sd) array acting on Cartesian weights. + """ + sd = ref_el.get_spatial_dimension() + detJ = np.linalg.det(J) + nu = ref_el.compute_scaled_normal(entity) + tangents = ref_el.compute_tangents(sd - 1, entity) + Ghat = np.column_stack([nu, *tangents]) + if sd == 2: + K = detJ * np.linalg.inv(J).T + P = np.column_stack([K @ nu, J @ tangents[0]]) + return (J.T / detJ) @ P @ np.linalg.inv(Ghat) + M = J.T @ J + Minv_nu = np.linalg.solve(M, nu) + nn = nu @ nu + N = np.zeros((sd, sd)) + N[0, 0] = 1.0 + for l, t in enumerate(tangents): + N[l + 1, l + 1] = detJ * (nu @ Minv_nu) / nn + N[0, l + 1] = -detJ * (t @ Minv_nu) / nn + return Ghat @ N @ np.linalg.inv(Ghat) + + +def composition_transformation(fiat_element, J): + """Compute V for a contravariant Piola element by duality alone. + + :arg fiat_element: The FIAT element on the reference cell. + :arg J: The (numeric) cell Jacobian. + :returns: The transformation V as a numpy array, with the same row and + column ordering as ``basis_transformation`` before truncation of + the trailing constraint columns. + """ + ref_el = fiat_element.get_reference_element() + sd = ref_el.get_spatial_dimension() + detJ = np.linalg.det(J) + Theta_T = J.T / detJ + nodes = fiat_element.dual_basis() + entity_ids = fiat_element.entity_dofs() + B = np.eye(len(nodes)) + for dim in entity_ids: + for entity in entity_ids[dim]: + for i in entity_ids[dim][entity]: + ell = PhysicallyMappedFunctional.from_fiat(nodes[i]) + if ell.divergence: + B[i] = evaluate_divergence(ell, fiat_element) / detJ + continue + point_data = (len(ell.points) == 1 + and (ell.rank == 1 or dim != sd - 1)) + if dim == sd and not point_data: + continue + if dim == sd - 1 and not point_data: + net = facet_frame_net(ref_el, entity, J) + else: + net = Theta_T + W = ell.weights.reshape(-1, *(sd,) * ell.rank) + for _ in range(ell.rank): + W = np.tensordot(W, net, axes=(1, 1)) + W = W.reshape(len(ell.points), -1) + B[i] = PhysicallyMappedFunctional( + ell.points, W, rank=ell.rank).evaluate(fiat_element) + return np.linalg.inv(B) + + +piola_zoo = { + 2: [(finat.MardalTaiWinther, ()), + (finat.JohnsonMercier, ()), + (finat.ArnoldWintherNC, ()), + (finat.AlfeldSorokina, ()), + (finat.BernardiRaugel, ()), + (finat.BernardiRaugelBubble, ()), + (finat.GuzmanNeilanFirstKindH1, ()), + (finat.GuzmanNeilanSecondKindH1, ()), + (finat.GuzmanNeilanBubble, ()), + (finat.GuzmanNeilanH1div, ()), + (finat.ReducedArnoldQin, ()), + (finat.ChristiansenHu, ()), + (finat.HuZhang, (3, "integral")), + (finat.HuZhang, (4, "integral")), + (finat.HuZhang, (3, "point")), + (finat.HuZhang, (4, "point"))], + 3: [(finat.MardalTaiWinther, ()), + (finat.MardalTaiWinther, (2,)), + (finat.JohnsonMercier, ()), + (finat.AlfeldSorokina, ()), + (finat.BernardiRaugel, ()), + (finat.BernardiRaugelBubble, ()), + (finat.GuzmanNeilanFirstKindH1, ()), + (finat.GuzmanNeilanFirstKindH1, (2,)), + (finat.GuzmanNeilanSecondKindH1, ()), + (finat.GuzmanNeilanBubble, ()), + (finat.GuzmanNeilanH1div, ()), + (finat.ChristiansenHu, ())], +} + +orientations = { + (2, "positive"): ((0.0, 0.1), (1.17, -0.09), (0.15, 1.84)), + (2, "negative"): ((0.0, 0.1), (0.15, 1.84), (1.17, -0.09)), + (3, "positive"): ((0, 0, 0), (1., 0.1, -0.37), + (0.01, 0.987, -.23), (-0.1, -0.2, 1.38)), + (3, "negative"): ((0, 0, 0), (0.01, 0.987, -.23), + (1., 0.1, -0.37), (-0.1, -0.2, 1.38)), +} + + +@pytest.mark.parametrize("orientation", ["positive", "negative"]) +@pytest.mark.parametrize("dimension, element, args", [ + (dim, *case) for dim in piola_zoo for case in piola_zoo[dim]]) +def test_piola_composition(dimension, element, args, orientation): + ref_cell = ufc_simplex(dimension) + phys_cell = ufc_simplex(dimension) + phys_cell.vertices = orientations[(dimension, orientation)] + J, b = make_affine_mapping(ref_cell.vertices, phys_cell.vertices) + + finat_element = element(ref_cell, *args) + mapping = MyMapping(ref_cell, phys_cell) + M = evaluate([finat_element.basis_transformation(mapping)])[0].arr + + V = composition_transformation(finat_element._element, J) + V = V[:, :finat_element.space_dimension()] + assert np.allclose(V, M.T, atol=1e-10) From dcc8a75cd0cf6a3be1a8d6b9e150781718aeccad Mon Sep 17 00:00:00 2001 From: Pablo Brubeck Date: Fri, 17 Jul 2026 22:12:40 +0100 Subject: [PATCH 29/34] update live notebook --- zany_claude.md | 128 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 124 insertions(+), 4 deletions(-) diff --git a/zany_claude.md b/zany_claude.md index c76a3f2ff..5261a83a6 100644 --- a/zany_claude.md +++ b/zany_claude.md @@ -664,7 +664,127 @@ $N$ above. Newly automatable for free: GN2, HuZhang (both variants), BR, CH, RAQ and AW/AWnc once `from_fiat` learns tensor-divergence moments. Covariant (Nedelec -type zany) elements: theory ready, value slot $\equiv$ derivative slot. -Verification artifacts: prototype scripts run in-session 2026-07-17 (see git log -of this file for the summary; the prototype is small enough to re-derive from -items 1-6, and Stage 5 will turn it into the production implementation with -symbolic $J$). +Verification artifacts: the prototype now lives in the repo as +`test/finat/test_zany_composition.py`, upgraded from the in-session scripts: +it computes $V = B^{-1}$ by *block back-substitution* (no dense inversion +anywhere) and asserts both the support law of $B$ (see the Stage 5 design +below) and agreement with `basis_transformation`, for the whole zoo, 2D and +3D, both orientations (56 parametrized cases). + +### Stage 5 design (2026-07-17): sparsity, cost, and the unified elimination + +Robert's (correct) objection to the first prototype: it formed $B$ densely and +called `inv`. That was a verification shortcut, not the algorithm. This section +records the sparsity theory that makes the duality formulation *no less sparse +and no more expensive* than the current hand-structured code, and the +implementation design in enough detail to be reviewed before touching +`finat/zany.py`. + +**1. Support law (sparsity theorem for $B$).** Order the dofs by the entity +partial order: $(d, e) \preceq (d', e')$ iff $e \subseteq +\overline{e'}$ (closure). Claim: $B_{ij} = F_*(n_i)(\hat\psi_j) \ne 0$ only if +$\mathrm{entity}(j) \preceq \mathrm{entity}(i)$, so $B$ is block lower +triangular with per-entity diagonal blocks. Sharper, by dof kind: + +* *Single-point rows* (vertex/edge/interior Cartesian point data): exactly + block-diagonal within the point group. Proof: $F_*(n)$ at a point $\hat x$ is + the $\Theta^T$-combination of the component-evaluation nodes at $\hat x$ — a + pointwise identity of functionals on $C^0$, needing no polynomial exactness. +* *Divergence rows*: $B_i = e_i/\det J$ exactly. Proof: the divergence miracle + $(K^{-1}\!\otimes\!J^{-1})\delta = \delta/\det J$ turns $F_*(\ell_{div})$ + into $\hat\ell_{div}/\det J$, and $\hat\ell_{div}$ *is* a reference node, so + its generalized Vandermonde row is $e_i$. +* *Interior invariant moments*: $B_i = e_i$ by definition of invariance. +* *Facet rows*: support $\subseteq$ facet block $\cup$ dofs on the strict + closure $\partial f$. Proof sketch: decompose the slot-mapped weight in the + reference frame; the frame components give the facet-block entries (the net + $N$); the residual $R$ is a moment supported on $f$, hence a functional of + the trace of its argument on $f$. For $j \notin \overline{f}$, $\hat\psi_j$ + has vanishing dofs on $\overline{f}$, and *trace unisolvence* — the dofs on + $\overline{f}$ determine the traces on $f$ that facet functionals sense, + i.e. exactly the property that makes the element (H1- or H(div)-) + assemblable across facets — forces $R(\hat\psi_j) = 0$. This is the Piola + analogue of the scalar exactness identities (paper Prop 2.1): in Morley, + "the trace on $e$ is determined by closure dofs" is implemented by FTOC + along the edge. **Sparsity of $B$ = conformity of the dof layout.** + +Verified numerically (scratch script, 2026-07-17): max $|B_{ij}|$ outside the +predicted support is $0$ to machine precision for all 28 element instances of +the zoo, on a negatively oriented cell; the test file asserts the same law on +both orientations on every run. + +**2. Elimination = block back-substitution = the paper's row recursion.** +$BV = I$ with $B$ block triangular gives, per entity $e$ with closure dof set +$c$ (already processed, since dims are visited in increasing order): +$$V_e = B_{ee}^{-1}\,(I_e - B_{ec} V_c).$$ +This is the scalar row recursion $V_i = c\,e_i + \sum_{j \in P} \gamma_{ij} +V_j$ in matrix clothing. Fill-in: $\mathrm{supp}(V_e) \subseteq e \cup c$, +because the closure relation is *transitive* — the predicted fill equals the +final fill, nothing grows during the elimination. Depth of the recursion +$\le sd$ (vertex → edge → face → interior). At no point is a dense matrix +formed or inverted; the prototype's `np.linalg.inv` is gone +(`composition_transformation` in `test/finat/test_zany_composition.py` now +implements exactly this loop and asserts the support law en route). + +**3. Diagonal blocks invert in closed form (the symbolic cost).** The only +$J$-dependent inverses ever needed are: + +* point group: inverse slot map $= K$ per slot (already what + `_piola_point_rows` emits; $K = \mathrm{adj}(J)$ is *polynomial* in $J$); +* divergence: $\det J$ (polynomial); +* facet frame: $N$ is block triangular + $\begin{pmatrix}1 & r_l\\ 0 & sI\end{pmatrix}$ with **scalar** diagonal, so + $N^{-1} = \begin{pmatrix}1 & -r_l/s\\ 0 & s^{-1}I\end{pmatrix}$, and the + coefficients are Gram contractions of the $K$-mapped frame — no symbolic + $M^{-1} = (J^TJ)^{-1}$ solve, since $J^{-T}\hat\nu = K\hat\nu/\det J$: + $$s = \frac{|K\hat\nu^s|^2}{\det J\,|\hat\nu^s|^2} + = \frac{|\nu^s|^2}{\det J\,|\hat\nu^s|^2}, \qquad + r_l = -\frac{(K\hat t_l)\cdot(K\hat\nu^s)}{\det J\,|\hat\nu^s|^2}$$ + (verified to 1e-15, both orientations, 2D and 3D). Note the mirror of the + scalar edge coefficient $c_e = \det J\,|\hat t_e|/|J\hat t_e|$: numerator + and denominator swap roles under $J \leftrightarrow K$, tangent + $\leftrightarrow$ normal — the covariant/contravariant duality again. + +Everything else in $B_{ee}$ and $B_{ec}$ is *numeric and $J$-independent*: +restricted generalized Vandermonde data (reference tabulations contracted +with reference weights), computable and cacheable at element construction. +Each symbolic entry of $V_e$ is a sum of $\le$ (number of frame monomials) +terms (numeric constant) $\times$ (monomial in $\{1, s, r_l, \det J, K_{ab}\}$) +— for rank-$r$ elements the monomials come from $N^{\otimes r}$, still a fixed +finite set. This is the paper's $V = E\,V^c D$ factorization surfacing again: +numeric completion $E$, symbolic (block-)diagonal $V^c$, diagonal scaling $D$. +Per-dof symbolic cost: $O(sd^2)$ rational scalars; total GEM size $O(n\,sd^2)$ +— no symbolic LU, and *cheaper* than the current `PiolaFacetFrame` path, which +builds a symbolic physical Gram $G$ and its determinant for `Sinv`. + +**4. Implementation plan (what actually changes, reviewable pieces).** + +1. `finat/functional.py`: give `PhysicallyMappedFunctional` typed value slots + (mapping string per slot: affine / contravariant / covariant) and one + `pushforward(J, K)` that applies $J^{-1}$ per derivative slot (the existing + `pullback`) and $\Theta^T$ resp. $J^{-1}$ per value slot. Extend `from_fiat` + to tensor-divergence moments (trace over one derivative+value slot pair, + remaining value slot kept) — unlocks AW/AWnc. +2. `finat/zany.py`: one `basis_transformation` loop over entity blocks in + increasing dimension (the skeleton already exists); per-block behavior + chosen from *data* (divergence flag, single-point, entity dim, mapping + string), not from the Scalar/Piola subclass. `FacetFrame` and + `PiolaFacetFrame` merge into one frame class parametrized by the stable + subspace; it precomputes the numeric restricted Vandermonde/profile data at + construction and emits the $\{c\ \text{or}\ s, r_l, \det J\}$ closed forms + at transformation time. Delete `Y`/`Sinv`; `_piola_point_rows` and + `_divergence_rows` become instances of the generic slot-map inverse. +3. Fix the interior point-data rule (`invariant_dofs`): single-point order-0 + nodes are point data (K blocks), multi-point are invariant moments — + unlocks GN2 and HuZhang-point. +4. Runtime guard = the support law: assert the numeric residual expansion + vanishes outside the closure block (reference-time, once per element), so + a non-conforming dof layout fails loudly instead of producing a dense or + wrong $V$ (the generalization of Algorithm 1's assert line). +5. Tests: keep `test_zany_composition.py` as the independent numeric ground + truth (it shares no code path with `basis_transformation`); add + reflected-cell fixtures to `test_zany_mapping.py`. + +Risk framing unchanged from PLAN.md: abandon any piece that adds more +complexity than it removes; the theory above is the review artifact to be +signed off *before* production code moves. From 52dd53841d44d36142db5f045b3053cf03df5938 Mon Sep 17 00:00:00 2001 From: Pablo Brubeck Date: Fri, 17 Jul 2026 22:46:57 +0100 Subject: [PATCH 30/34] Stage 5 WIP --- finat/arnold_qin.py | 11 +- finat/aw.py | 107 +------ finat/bernardi_raugel.py | 12 +- finat/christiansen_hu.py | 11 +- finat/functional.py | 29 +- finat/guzman_neilan.py | 11 +- finat/hz.py | 46 +-- finat/piola_mapped.py | 143 +-------- finat/wuxu.py | 2 +- finat/zany.py | 448 ++++++++++++++++----------- test/finat/test_claude_zany_piola.py | 121 ++++++-- zany_claude.md | 6 +- 12 files changed, 476 insertions(+), 471 deletions(-) diff --git a/finat/arnold_qin.py b/finat/arnold_qin.py index 67d9cc378..7daf559fa 100644 --- a/finat/arnold_qin.py +++ b/finat/arnold_qin.py @@ -3,6 +3,7 @@ from finat.citations import cite from finat.fiat_elements import FiatElement from finat.piola_mapped import PiolaBubbleElement +from finat.zany import PiolaPhysicallyMappedElement class ArnoldQin(FiatElement): @@ -11,7 +12,15 @@ def __init__(self, cell, degree=2): super().__init__(FIAT.ArnoldQin(cell, degree)) -class ReducedArnoldQin(PiolaBubbleElement): +class ReducedArnoldQin(PiolaPhysicallyMappedElement, PiolaBubbleElement): + """The reduced Arnold-Qin element. + + The basis transformation is derived automatically from the FIAT + dual basis (see :class:`finat.zany.PiolaPhysicallyMappedElement`); + the trailing tangential facet constraints of the extended element + are dropped from the physical element by + :meth:`~finat.piola_mapped.PiolaBubbleElement.space_dimension`. + """ def __init__(self, cell, degree=2): cite("ArnoldQin1992") super().__init__(FIAT.ArnoldQin(cell, degree, reduced=True)) diff --git a/finat/aw.py b/finat/aw.py index c94000315..0b23f60e8 100644 --- a/finat/aw.py +++ b/finat/aw.py @@ -1,75 +1,23 @@ """Implementation of the Arnold-Winther finite elements.""" import FIAT -import numpy -from gem import ListTensor from finat.citations import cite from finat.fiat_elements import FiatElement -from finat.physically_mapped import adjugate, identity, PhysicallyMappedElement -from finat.piola_mapped import normal_tangential_transform +from finat.zany import PiolaPhysicallyMappedElement -def _facet_transform(fiat_cell, facet_moment_degree, coordinate_mapping): - sd = fiat_cell.get_spatial_dimension() - top = fiat_cell.get_topology() - num_facets = len(top[sd-1]) - dimPk_facet = FIAT.expansions.polynomial_dimension( - fiat_cell.construct_subelement(sd-1), facet_moment_degree) - dofs_per_facet = sd * dimPk_facet - ndofs = num_facets * dofs_per_facet - V = identity(ndofs) +class ArnoldWintherNC(PiolaPhysicallyMappedElement, FiatElement): + """The nonconforming Arnold-Winther element. - bary, = fiat_cell.make_points(sd, 0, sd+1) - J = coordinate_mapping.jacobian_at(bary) - detJ = coordinate_mapping.detJ_at(bary) - for f in range(num_facets): - Bnt, Btt = normal_tangential_transform(fiat_cell, J, detJ, f) - for i in range(dimPk_facet): - s = dofs_per_facet*f + i * sd - ndof = s - tdofs = range(s+1, s+sd) - V[tdofs, ndof] = Bnt - V[tdofs, tdofs] = Btt - - return V - - -def _evaluation_transform(fiat_cell, coordinate_mapping): - sd = fiat_cell.get_spatial_dimension() - bary, = fiat_cell.make_points(sd, 0, sd+1) - J = coordinate_mapping.jacobian_at(bary) - K = adjugate([[J[i, j] for j in range(sd)] for i in range(sd)]) - - indices = [(i, j) for i in range(sd) for j in range(i, sd)] - ncomp = len(indices) - W = numpy.zeros((ncomp, ncomp), dtype=object) - for p, (i, j) in enumerate(indices): - for q, (m, n) in enumerate(indices): - W[p, q] = 0.5*(K[i, m] * K[j, n] + K[j, m] * K[i, n]) - W[:, [i != j for i, j in indices]] *= 2 - return W - - -class ArnoldWintherNC(PhysicallyMappedElement, FiatElement): + The basis transformation is derived automatically from the FIAT + dual basis (see :class:`finat.zany.PiolaPhysicallyMappedElement`); + the trailing constraint functionals of the extended element are + dropped from the physical element by :meth:`space_dimension`. + """ def __init__(self, cell, degree=2): cite("Arnold2003") super().__init__(FIAT.ArnoldWintherNC(cell, degree)) - def basis_transformation(self, coordinate_mapping): - """Note, the extra 3 dofs which are removed here - correspond to the constraints.""" - numbf = self._element.space_dimension() - ndof = self.space_dimension() - V = identity(numbf, ndof) - - V[:12, :12] = _facet_transform(self.cell, 1, coordinate_mapping) - - # Note: that the edge DOFs are scaled by edge lengths in FIAT implies - # that they are already have the necessary rescaling to improve - # conditioning. - - return ListTensor(V.T) - def entity_dofs(self): return {0: {0: [], 1: [], @@ -81,41 +29,18 @@ def space_dimension(self): return 15 -class ArnoldWinther(PhysicallyMappedElement, FiatElement): +class ArnoldWinther(PiolaPhysicallyMappedElement, FiatElement): + """The conforming Arnold-Winther element. + + The basis transformation is derived automatically from the FIAT + dual basis (see :class:`finat.zany.PiolaPhysicallyMappedElement`); + the trailing constraint functionals of the extended element are + dropped from the physical element by :meth:`space_dimension`. + """ def __init__(self, cell, degree=3): cite("Arnold2002") super().__init__(FIAT.ArnoldWinther(cell, degree)) - def basis_transformation(self, coordinate_mapping): - # The extra 6 dofs removed here correspond to the constraints - numbf = self._element.space_dimension() - ndof = self.space_dimension() - V = identity(numbf, ndof) - - sd = self.cell.get_spatial_dimension() - W = _evaluation_transform(self.cell, coordinate_mapping) - ncomp = W.shape[0] - - # Put into the right rows and columns. - V[0:3, 0:3] = V[3:6, 3:6] = V[6:9, 6:9] = W - num_verts = sd + 1 - cur = num_verts * ncomp - - Vsub = _facet_transform(self.cell, 1, coordinate_mapping) - fdofs = Vsub.shape[0] - V[cur:cur+fdofs, cur:cur+fdofs] = Vsub - cur += fdofs - - # RESCALING FOR CONDITIONING - h = coordinate_mapping.cell_size() - for e in range(num_verts): - V[:, ncomp*e:ncomp*(e+1)] *= 1/(h[e] * h[e]) - - # Note: that the edge DOFs are scaled by edge lengths in FIAT implies - # that they are already have the necessary rescaling to improve - # conditioning. - return ListTensor(V.T) - def entity_dofs(self): return {0: {0: [0, 1, 2], 1: [3, 4, 5], diff --git a/finat/bernardi_raugel.py b/finat/bernardi_raugel.py index 4dd461f87..2e35c3cbd 100644 --- a/finat/bernardi_raugel.py +++ b/finat/bernardi_raugel.py @@ -2,14 +2,24 @@ from finat.citations import cite from finat.piola_mapped import PiolaBubbleElement +from finat.zany import PiolaPhysicallyMappedElement -class BernardiRaugel(PiolaBubbleElement): +class BernardiRaugel(PiolaPhysicallyMappedElement, PiolaBubbleElement): + """The Bernardi-Raugel element. + + The basis transformation is derived automatically from the FIAT + dual basis (see :class:`finat.zany.PiolaPhysicallyMappedElement`); + the trailing tangential facet constraints of the extended element + are dropped from the physical element by + :meth:`~finat.piola_mapped.PiolaBubbleElement.space_dimension`. + """ def __init__(self, cell, order=1, quad_scheme=None): cite("BernardiRaugel1985") super().__init__(FIAT.BernardiRaugel(cell, order=order, quad_scheme=quad_scheme)) class BernardiRaugelBubble(BernardiRaugel): + """The normal facet bubbles of the Bernardi-Raugel element.""" def __init__(self, cell, degree=None, quad_scheme=None): super().__init__(cell, order=0, quad_scheme=quad_scheme) diff --git a/finat/christiansen_hu.py b/finat/christiansen_hu.py index c8b4babc0..018b1f6f0 100644 --- a/finat/christiansen_hu.py +++ b/finat/christiansen_hu.py @@ -2,9 +2,18 @@ from finat.citations import cite from finat.piola_mapped import PiolaBubbleElement +from finat.zany import PiolaPhysicallyMappedElement -class ChristiansenHu(PiolaBubbleElement): +class ChristiansenHu(PiolaPhysicallyMappedElement, PiolaBubbleElement): + """The Christiansen-Hu element. + + The basis transformation is derived automatically from the FIAT + dual basis (see :class:`finat.zany.PiolaPhysicallyMappedElement`); + the trailing tangential facet constraints of the extended element + are dropped from the physical element by + :meth:`~finat.piola_mapped.PiolaBubbleElement.space_dimension`. + """ def __init__(self, cell, degree=1): cite("ChristiansenHu2019") super().__init__(FIAT.ChristiansenHu(cell, degree)) diff --git a/finat/functional.py b/finat/functional.py index 82c0dd606..3d0a059c8 100644 --- a/finat/functional.py +++ b/finat/functional.py @@ -60,21 +60,30 @@ class PhysicallyMappedFunctional: recovered and handled as its own case because it commutes with the (contravariant) Piola pullback up to the Jacobian determinant, independently of the entity it sits on. + mapping : + The FIAT mapping string of the basis functions this functional + is dual to (``"affine"``, ``"contravariant piola"``, ...): the + type tag of the value slots, deciding which matrix each value + slot of the weight tensor is contracted with under push-forward, + exactly as the derivative slots of ``direction`` are contracted + with the Jacobian. """ def __init__(self, points: tuple, weights: numpy.ndarray, order: int = 0, direction=None, rank: int = 0, - divergence: bool = False): + divergence: bool = False, mapping: str = "affine"): self.points = points self.weights = weights self.order = order self.direction = direction self.rank = rank self.divergence = divergence + self.mapping = mapping @classmethod - def from_fiat(cls, node: FIATFunctional, tol: float = 1e-12) -> "PhysicallyMappedFunctional": + def from_fiat(cls, node: FIATFunctional, tol: float = 1e-12, + mapping: str = "affine") -> "PhysicallyMappedFunctional": """Construct a symbolic PhysicallyMappedFunctional from a FIAT functional. The construction only inspects the point and derivative @@ -89,6 +98,9 @@ def from_fiat(cls, node: FIATFunctional, tol: float = 1e-12) -> "PhysicallyMappe tol : Relative tolerance for the rank-one factorization of the derivative weights. + mapping : + The FIAT mapping string of the basis functions this + functional is dual to. Returns ------- @@ -107,7 +119,7 @@ def from_fiat(cls, node: FIATFunctional, tol: float = 1e-12) -> "PhysicallyMappe if rank == 0: weights = numpy.asarray([w for pt in points for w, comp in node.pt_dict[pt]]) - return cls(points, weights) + return cls(points, weights, mapping=mapping) # value weight profile: one row of component weights per point sd = node.ref_el.get_spatial_dimension() weights = numpy.zeros((len(points), sd**rank)) @@ -115,7 +127,7 @@ def from_fiat(cls, node: FIATFunctional, tol: float = 1e-12) -> "PhysicallyMappe for q, pt in enumerate(points): for w, comp in node.pt_dict[pt]: weights[q, numpy.ravel_multi_index(comp, shape)] += w - return cls(points, weights, rank=rank) + return cls(points, weights, rank=rank, mapping=mapping) sd = node.ref_el.get_spatial_dimension() order = node.max_deriv_order @@ -144,7 +156,8 @@ def from_fiat(cls, node: FIATFunctional, tol: float = 1e-12) -> "PhysicallyMappe raise NotImplementedError( f"{type(node).__name__} is not a divergence functional.") weights[q] = Wq[0, 0] - return cls(points, weights, order=order, divergence=True) + return cls(points, weights, order=order, divergence=True, + mapping=mapping) W = numpy.zeros((len(points), len(alphas))) for q, pt in enumerate(points): @@ -158,12 +171,14 @@ def from_fiat(cls, node: FIATFunctional, tol: float = 1e-12) -> "PhysicallyMappe f"{type(node).__name__} has no common derivative direction.") direction = vt[0] weights = u[:, 0] * s[0] - return cls(points, weights, order=order, direction=direction) + return cls(points, weights, order=order, direction=direction, + mapping=mapping) def with_direction(self, direction) -> "PhysicallyMappedFunctional": """Return the same functional with another direction tensor.""" return type(self)(self.points, self.weights, - order=self.order, direction=direction) + order=self.order, direction=direction, + mapping=self.mapping) def pullback(self, J: Node) -> "PhysicallyMappedFunctional": r"""View this reference functional as acting on physical functions. diff --git a/finat/guzman_neilan.py b/finat/guzman_neilan.py index 0390925be..c3be03ee0 100644 --- a/finat/guzman_neilan.py +++ b/finat/guzman_neilan.py @@ -19,8 +19,15 @@ def __init__(self, cell, order=1, quad_scheme=None): super().__init__(FIAT.GuzmanNeilanFirstKindH1(cell, order=order, quad_scheme=quad_scheme)) -class GuzmanNeilanSecondKindH1(PiolaBubbleElement): - """C0 Pk^d(Alfeld) enriched with Guzman-Neilan bubbles.""" +class GuzmanNeilanSecondKindH1(PiolaPhysicallyMappedElement, PiolaBubbleElement): + """C0 Pk^d(Alfeld) enriched with Guzman-Neilan bubbles. + + The basis transformation is derived automatically from the FIAT + dual basis (see :class:`finat.zany.PiolaPhysicallyMappedElement`); + the trailing tangential facet constraints of the extended element + are dropped from the physical element by + :meth:`~finat.piola_mapped.PiolaBubbleElement.space_dimension`. + """ def __init__(self, cell, order=1, quad_scheme=None): cite("GuzmanNeilan2018") super().__init__(FIAT.GuzmanNeilanSecondKindH1(cell, order=order, quad_scheme=quad_scheme)) diff --git a/finat/hz.py b/finat/hz.py index 4e84da321..2212d15d5 100644 --- a/finat/hz.py +++ b/finat/hz.py @@ -1,48 +1,18 @@ """Implementation of the Hu-Zhang finite elements.""" import FIAT -from gem import ListTensor + from finat.citations import cite from finat.fiat_elements import FiatElement -from finat.physically_mapped import identity, PhysicallyMappedElement -from finat.aw import _facet_transform, _evaluation_transform +from finat.zany import PiolaPhysicallyMappedElement + +class HuZhang(PiolaPhysicallyMappedElement, FiatElement): + """The Hu-Zhang element. -class HuZhang(PhysicallyMappedElement, FiatElement): + The basis transformation is derived automatically from the FIAT + dual basis; see :class:`finat.zany.PiolaPhysicallyMappedElement`. + """ def __init__(self, cell, degree=3, variant=None, quad_scheme=None): cite("Hu2015") self.variant = variant super().__init__(FIAT.HuZhang(cell, degree, variant=variant, quad_scheme=quad_scheme)) - - def basis_transformation(self, coordinate_mapping): - ndofs = self.space_dimension() - V = identity(ndofs) - - sd = self.cell.get_spatial_dimension() - W = _evaluation_transform(self.cell, coordinate_mapping) - - # Put into the right rows and columns. - V[0:3, 0:3] = V[3:6, 3:6] = V[6:9, 6:9] = W - ncomp = W.shape[0] - num_verts = sd+1 - cur = num_verts * ncomp - - Vsub = _facet_transform(self.cell, self.degree-2, coordinate_mapping) - fdofs = Vsub.shape[0] - V[cur:cur+fdofs, cur:cur+fdofs] = Vsub - cur += fdofs - - # internal DOFs - if self.variant == "point": - while cur < ndofs: - V[cur:cur+ncomp, cur:cur+ncomp] = W - cur += ncomp - - # RESCALING FOR CONDITIONING - h = coordinate_mapping.cell_size() - for e in range(num_verts): - V[:, ncomp*e:ncomp*(e+1)] *= 1/(h[e] * h[e]) - - # Note: that the edge DOFs are scaled by edge lengths in FIAT implies - # that they are already have the necessary rescaling to improve - # conditioning. - return ListTensor(V.T) diff --git a/finat/piola_mapped.py b/finat/piola_mapped.py index 447237e26..00debd110 100644 --- a/finat/piola_mapped.py +++ b/finat/piola_mapped.py @@ -1,78 +1,21 @@ -import numpy - -from finat.fiat_elements import FiatElement -from finat.physically_mapped import adjugate, determinant, identity, PhysicallyMappedElement -from gem import Literal, ListTensor, Zero +"""Reduced Piola-mapped elements with normal facet bubbles.""" from copy import deepcopy -from itertools import chain - - -def piola_inverse(fiat_cell, J, detJ): - """Return the basis transformation of evaluation at a point. - This simply inverts the Piola transform inv(J / detJ) = adj(J).""" - sd = fiat_cell.get_spatial_dimension() - Jnp = numpy.array([[J[i, j] for j in range(sd)] for i in range(sd)]) - return adjugate(Jnp) - - -def normal_tangential_edge_transform(fiat_cell, J, detJ, f): - """Return the basis transformation of - normal and tangential edge moments""" - R = numpy.array([[0, 1], [-1, 0]]) - that = fiat_cell.compute_edge_tangent(f) - that /= numpy.linalg.norm(that) - nhat = R @ that - Jn = J @ Literal(nhat) - Jt = J @ Literal(that) - alpha = Jn @ Jt - beta = Jt @ Jt - # Compute the last row of inv([[1, 0], [alpha/detJ, beta/detJ]]) - return (-1 * alpha / beta, detJ / beta) - - -def normal_tangential_face_transform(fiat_cell, J, detJ, f): - """Return the basis transformation of - normal and tangential face moments""" - # Compute the reciprocal basis - thats = fiat_cell.compute_tangents(2, f) - nhat = numpy.cross(*thats) - nhat /= numpy.dot(nhat, nhat) - orths = numpy.cross(thats, nhat[None, :], axis=1) - - Jn = J @ Literal(nhat) - Jthats = J @ Literal(thats.T) - Jorths = J @ Literal(orths.T) - A = Jthats.T @ Jorths - B = Jn @ Jthats - A = numpy.array([[A[i, j] for j in range(A.shape[1])] for i in range(A.shape[0])]) - B = numpy.array([B[i] for i in range(B.shape[0])]) - - Q = numpy.dot(thats, thats.T) - beta = determinant(A) - alpha = Q @ (adjugate(A) @ B) - return (alpha / beta, detJ / beta) - - -def normal_tangential_transform(fiat_cell, J, detJ, f): - """Return the basis transformation of normal and tangential face moments - :arg fiat_cell: a :class:`FIAT.reference_element.Cell` - :arg J: the Jacobian of the coordinate transformation - :arg detJ: the Jacobian determinant of the coordinate transformation - :arg f: the face id. - - :returns: a 2-tuple of (Bnt, Btt) where - Bnt is the numpy.ndarray of normal-tangential coefficients, and - Btt is the tangential-tangential coefficient. - """ - if fiat_cell.get_spatial_dimension() == 2: - return normal_tangential_edge_transform(fiat_cell, J, detJ, f) - else: - return normal_tangential_face_transform(fiat_cell, J, detJ, f) +from finat.fiat_elements import FiatElement +from finat.physically_mapped import PhysicallyMappedElement class PiolaBubbleElement(PhysicallyMappedElement, FiatElement): - """A general class to transform Piola-mapped elements with normal facet bubbles.""" + """Dof-reduction wrapper for Piola-mapped elements with normal facet bubbles. + + The FIAT element is an extended element carrying tangential facet + functionals as trailing constraints; this wrapper exposes only the + normal facet bubble on each facet, following the reduced/constrained + element convention (rectangular transformation, truncated to + :meth:`space_dimension` columns). The basis transformation itself is + provided by :class:`finat.zany.PiolaPhysicallyMappedElement`, which + concrete subclasses list first in their bases. + """ def __init__(self, fiat_element): mapping, = set(fiat_element.mapping()) if mapping != "contravariant piola": @@ -97,63 +40,3 @@ def entity_dofs(self): def space_dimension(self): return self._space_dimension - - def basis_transformation(self, coordinate_mapping): - sd = self.cell.get_spatial_dimension() - bary, = self.cell.make_points(sd, 0, sd+1) - J = coordinate_mapping.jacobian_at(bary) - detJ = coordinate_mapping.detJ_at(bary) - - dofs = self.entity_dofs() - bfs = self._element.entity_dofs() - numdof = self.space_dimension() - numbf = self._element.space_dimension() - V = identity(numbf, numdof) - - # Undo the Piola transform for non-facet bubble basis functions - nodes = self._element.get_dual_set().nodes - Finv = piola_inverse(self.cell, J, detJ) - for dim in dofs: - if dim == sd-1: - continue - for e in sorted(dofs[dim]): - k = 0 - while k < len(dofs[dim][e]): - cur = dofs[dim][e][k] - if len(nodes[cur].deriv_dict) > 0: - V[cur, cur] = detJ - k += 1 - else: - s = dofs[dim][e][k:k+sd] - V[numpy.ix_(s, s)] = Finv - k += sd - # Unpick the normal component for the facet bubbles - for f in sorted(dofs[sd-1]): - Bnt, Btt = normal_tangential_transform(self.cell, J, detJ, f) - ndof, *tdofs = dofs[sd-1][f] - nbf, *tbfs = bfs[sd-1][f] - V[tbfs, ndof] = Bnt - if len(tdofs) > 0: - V[tbfs, tdofs] = Btt - - # Fix discrepancy between normal and tangential moments - needs_facet_vertex_coupling = len(dofs[0][0]) > 0 and numbf > numdof - if needs_facet_vertex_coupling: - perp = lambda *t: numpy.array([t[0][1], -t[0][0]]) if len(t) == 1 else numpy.cross(*t) - - dim = max(d for d in range(sd-1) if len(dofs[d][0]) > 0) - vdofs = chain.from_iterable(dofs[dim].values()) - vdofs = [i for i in vdofs if nodes[i].max_deriv_order == 0] - fdofs = list(chain.from_iterable(dofs[sd-1].values())) - - T = numpy.full((len(fdofs), len(vdofs)), Zero(), dtype=object) - for f in sorted(dofs[sd-1]): - nhat = perp(*self.cell.compute_tangents(sd-1, f)) - Tfv = ((-1/sd) * nhat) @ Finv - for v in self.cell.connectivity[(sd-1, dim)][f]: - curvdofs = [vdofs.index(i) for i in dofs[dim][v] if i in vdofs] - for fdof in dofs[sd-1][f]: - T[fdofs.index(fdof), curvdofs] = Tfv - - V[numdof:, vdofs] += V[numdof:, fdofs] @ T - return ListTensor(V.T) diff --git a/finat/wuxu.py b/finat/wuxu.py index 7e1ecacb4..542e20a46 100644 --- a/finat/wuxu.py +++ b/finat/wuxu.py @@ -1,5 +1,5 @@ from finat.fiat_elements import ScalarFiatElement -from finat.physically_mapped import identity, PhysicallyMappedElement +from finat.physically_mapped import identity from finat.zany import ScalarPhysicallyMappedElement from finat.argyris import _vertex_transform from finat.citations import cite diff --git a/finat/zany.py b/finat/zany.py index 7051cd5ca..f3aca4eb7 100644 --- a/finat/zany.py +++ b/finat/zany.py @@ -110,7 +110,7 @@ from FIAT.finite_element import FiniteElement from gem import Literal, ListTensor, Node, Zero from finat.functional import PhysicallyMappedFunctional -from finat.physically_mapped import PhysicallyMappedElement, adjugate, determinant, identity, inverse +from finat.physically_mapped import PhysicallyMappedElement, adjugate, determinant, identity def _materialize_jacobian(J: Node) -> numpy.ndarray: @@ -218,13 +218,24 @@ class PiolaFacetFrame: The roles of the normal and tangential directions are mirrored with respect to :class:`FacetFrame`: the reference frame's scaled normal :math:`\hat{n}` maps by the cofactor matrix :math:`K = - \operatorname{adj}(J)^T`, while its tangents :math:`\hat{t}_k` map by - :math:`J` directly. ``Y`` is the (symbolic) matrix expanding the - pulled-back frame image :math:`[K\hat{n}\;|\;K\hat{t}_k]` in the - physical frame :math:`[K\hat{n}\;|\;J\hat{t}_k]`; the mapped tangents - are built on the reciprocal basis in dimension > 2, so their - coordinates carry an extra in-plane contravariant correction - :math:`S^{-1}` folded into ``Y``'s tangential rows. + \operatorname{adj}(J)^T`, while the physical tangential directions + (mapped tangents in 2D, cross products of the physical normal with + mapped in-plane vectors in 3D) push forward with a *scalar* + tangential coefficient. ``Y`` is the matrix expanding, in reference + frame coordinates, the pullback of a physical node's frame profile; + it has the closed form + + .. math:: + Y = \begin{pmatrix} 1 & (K\hat{t}_k \cdot K\hat{n})/|K\hat{n}|^2 \\ + 0 & \det J\, |\hat{n}|^2 / |K\hat{n}|^2\, I + \end{pmatrix}, + + valid in 2D and 3D alike: every entry is a Gram contraction of the + :math:`K`-mapped reference frame (:math:`K` is polynomial in + :math:`J`), divided by the single quadratic :math:`|K\hat{n}|^2`. + The top row carries the normal-direction completion residuals; the + diagonal tangential block is the reciprocal of the scalar + push-forward coefficient of the tangential directions. :arg fiat_element: The FIAT element, providing the reference cell. :arg entity: The facet number. @@ -242,32 +253,33 @@ def __init__(self, fiat_element: FiniteElement, entity: int, J: Node): Jnp = _materialize_jacobian(J) Knp = adjugate(Jnp).T + detJ = determinant(Jnp) - # Frame coordinates of the mapped frame image of the reference frame: - # the normal is invariant and the pulled tangents are expanded by a - # symbolic solve in the mapped frame [K nhat | J that_k] - Kn = self.normal @ Knp.T - Jt = self.tangents @ Jnp.T - A = numpy.column_stack([Kn, *Jt]) + Kn = Knp @ self.normal + qKn = Kn @ Kn + s_inv = detJ * (self.normal @ self.normal) / qKn Y = numpy.full((sd, sd), Zero(), dtype=object) Y[0, 0] = Literal(1.0) - Y[:, 1:] = inverse(A) @ (Knp @ self.tangents.T) - - # Physical tangential components are built on the reciprocal basis - # (cross products of the frame), so they carry the in-plane - # contravariant transformation S = adj(G Ghat^{-1})^T of the change - # of tangent Gram matrices. Absorb S^{-1} into the coordinate - # mixing so that the physical profiles keep the reference - # coordinates; in 2D the tangent plane is one-dimensional and S = 1. - G = Jt @ Jt.T - Ghat_t = self.tangents @ self.tangents.T - Sinv = (numpy.linalg.inv(Ghat_t) @ G) * \ - (numpy.linalg.det(Ghat_t) / determinant(G)) - Y[1:, :] = Sinv @ Y[1:, :] + for k, that in enumerate(self.tangents): + Y[0, k + 1] = ((Knp @ that) @ Kn) / qKn + Y[k + 1, k + 1] = s_inv self.Y = Y +def _contract_slots(W: numpy.ndarray, A: numpy.ndarray) -> numpy.ndarray: + r"""Contract every tensor slot of W with the matrix A. + + :arg W: A tensor with ``W.ndim`` slots, numeric or GEM entries. + :arg A: The matrix to contract each slot with, numeric or GEM entries. + :returns: The tensor with entries :math:`\sum A_{i_1 j_1} \cdots + A_{i_r j_r} W_{j_1 \dots j_r}`, with the slot order preserved. + """ + for _ in range(W.ndim): + W = numpy.tensordot(W, A, axes=(0, 1)) + return W + + def _weight_ratio(wi: numpy.ndarray, wj: numpy.ndarray, tol: float) -> float: """Return the scalar s with wi == s * wj, if it exists.""" s = wi @ wj / (wj @ wj) @@ -312,14 +324,29 @@ def basis_transformation(self, coordinate_mapping) -> ListTensor: J = coordinate_mapping.jacobian_at(bary) nodes = fiat_element.dual_basis() + mappings = fiat_element.mapping() + ndof = self.space_dimension() V = identity(fiat_element.space_dimension()) processed = set() entity_ids = fiat_element.entity_dofs() for dim in sorted(entity_ids): for entity in sorted(entity_ids[dim]): - group = {i: PhysicallyMappedFunctional.from_fiat(nodes[i]) - for i in entity_ids[dim][entity]} + group = {} + for i in entity_ids[dim][entity]: + try: + group[i] = PhysicallyMappedFunctional.from_fiat( + nodes[i], mapping=mappings[i]) + except NotImplementedError: + if i < ndof: + raise + # Constraint functionals of an extended element are + # never exposed as physical dofs: define their + # physical counterparts as the pullbacks of the + # reference ones (Kirby 2017, section 5), keeping the + # identity row; the corresponding columns are + # truncated below. + processed.add(i) invariant = self.invariant_dofs(group, dim, sd) processed.update(invariant) group = {i: ell for i, ell in group.items() if i not in invariant} @@ -330,10 +357,56 @@ def basis_transformation(self, coordinate_mapping) -> ListTensor: else: self.point_dof_rows(V, group, fiat_element, entity, J, processed, tol=self.tol) - _rescale_derivative_dofs(V, fiat_element, coordinate_mapping) - ndof = self.space_dimension() + self._rescale_dofs(V, fiat_element, coordinate_mapping) return ListTensor(V[:, :ndof].T) + def _rescale_dofs(self, V: numpy.ndarray, fiat_element: FiniteElement, + coordinate_mapping) -> None: + r"""Rescale the physical degrees of freedom by powers of the cell size. + + Each column of ``V`` is multiplied by :meth:`dof_scale` evaluated + with the cell size averaged over the vertices of the dof's entity. + This is the FInAT convention keeping the mass matrix + well-conditioned; it is consistent across cells because the + scaling only depends on shared entities. + + :arg V: Object array being assembled; columns are rescaled in place. + :arg fiat_element: The FIAT element defined on the reference cell. + :arg coordinate_mapping: Object providing the physical geometry as + GEM expressions. + """ + # cell_size may be a GEM expression or a numpy array of numbers + h = coordinate_mapping.cell_size() + top = fiat_element.get_reference_element().get_topology() + nodes = fiat_element.dual_basis() + entity_ids = fiat_element.entity_dofs() + for dim in entity_ids: + for entity in entity_ids[dim]: + verts = top[dim][entity] + havg = reduce(add, (h[v] for v in verts)) / len(verts) + for i in entity_ids[dim][entity]: + scale = self.dof_scale(nodes[i], dim, havg) + if scale is not None: + V[:, i] = V[:, i] * scale + + def dof_scale(self, node, dim: int, havg): + r"""Return the conditioning rescaling factor of one physical dof. + + The default convention redefines each physical node of derivative + order :math:`m > 0` with a factor :math:`h^{-m}`; elements whose + hand-coded transformations established a different convention + (e.g. the :math:`h^{-2}` vertex values of Hu-Zhang) override this + method. + + :arg node: The FIAT functional of the dof. + :arg dim: Topological dimension of the entity the dof sits on. + :arg havg: GEM scalar for the cell size averaged over the vertices + of the dof's entity. + :returns: The GEM scaling factor, or ``None`` for no rescaling. + """ + order = node.max_deriv_order + return havg**(-order) if order > 0 else None + def _check_mapping(self, fiat_element): """Verify that this class knows how to transform this element's pullback. @@ -387,35 +460,6 @@ def point_dof_rows(self, V, group, fiat_element, entity, J, processed, tol): pass -def _rescale_derivative_dofs(V, fiat_element, coordinate_mapping): - r"""Rescale derivative degrees of freedom by the cell size. - - Each physical node of derivative order :math:`m` is redefined with a - factor :math:`h^{-m}`, where :math:`h` averages the cell size over - the vertices of its entity. This is the FInAT convention keeping - the mass matrix well-conditioned; it is consistent across cells - because the scaling only depends on shared entities. - - :arg V: Object array being assembled; columns are rescaled in place. - :arg fiat_element: The FIAT element defined on the reference cell. - :arg coordinate_mapping: Object providing the physical geometry as - GEM expressions. - """ - # cell_size may be a GEM expression or a numpy array of numbers - h = coordinate_mapping.cell_size() - top = fiat_element.get_reference_element().get_topology() - nodes = fiat_element.dual_basis() - entity_ids = fiat_element.entity_dofs() - for dim in entity_ids: - for entity in entity_ids[dim]: - verts = top[dim][entity] - havg = reduce(add, (h[v] for v in verts)) / len(verts) - for i in entity_ids[dim][entity]: - order = nodes[i].max_deriv_order - if order > 0: - V[:, i] = V[:, i] * havg**(-order) - - class ScalarPhysicallyMappedElement(ZanyPhysicallyMappedElement): r"""Mixin deriving the basis transformation for a scalar element with an affine (identity) pullback. @@ -587,63 +631,75 @@ def _divergence_rows(V: numpy.ndarray, group: dict, J: Node, processed: set) -> return {i: ell for i, ell in group.items() if i not in divs} @staticmethod - def _is_cartesian_point_group(group: dict) -> bool: - """Recognize a group of Cartesian point-value nodes sharing one point. - - :arg group: Mapping from node index to symbolic PhysicallyMappedFunctional. - :returns: True if every node is a rank-1, order-0 node at the same - single point, spanning exactly as many components as the group - has members -- the same structural pattern :meth:`_piola_point_rows` - (and :meth:`ScalarPhysicallyMappedElement.point_dof_rows`) expect, - regardless of the entity dimension the group sits on. - """ - points = {ell.points for ell in group.values()} - return (len(points) == 1 and len(next(iter(points))) == 1 - and all(ell.order == 0 and ell.rank == 1 for ell in group.values())) - - @staticmethod - def _piola_point_rows(V: numpy.ndarray, group: dict, J: Node, - processed: set, tol: float) -> None: + def _piola_point_rows(V: numpy.ndarray, group: dict, fiat_element: FiniteElement, + J: Node, processed: set, tol: float) -> None: r"""Assemble the rows of V for Cartesian point values of Piola-mapped fields. Physical point evaluations keep the reference (Cartesian) components, which pull back through the cofactor matrix :math:`K = - \operatorname{adj}(J)^T` of the contravariant Piola map, so the group - of components sharing a point acts as its own completion. This is - the same treatment regardless of which entity the point sits on: a - single-point, rank-1 node group spanning the Cartesian components is - not a facet moment (whose weights are genuine, usually multi-point - quadrature averages aligned with the facet normal/tangential frame) - even when it happens to sit on a codimension-1 entity, e.g. the edge - dofs of :class:`~finat.alfeld_sorokina.AlfeldSorokina`. + \operatorname{adj}(J)^T` of the contravariant Piola map, contracted + once per value slot, so the group of components sharing a point acts + as its own completion. + + A weight tensor is only defined as a functional modulo the + annihilator of the value space attainable at the point: e.g. the + vertex weights of Hu-Zhang select one component of a *symmetric* + stress, so the raw upper-triangular weights and their symmetrizations + are the same functionals. The value space is recovered numerically + from the tabulated basis, both sides of the expansion are projected + onto it, and the group acts as its own completion if its projected + span is invariant under the (projected) slot map for every Jacobian, + which is checked with a generic matrix. :arg V: Object array being assembled. :arg group: Mapping from node index to symbolic PhysicallyMappedFunctional for the value nodes on this entity. + :arg fiat_element: The FIAT element, providing the tabulated value + space at each point. :arg J: GEM expression for the cell Jacobian. :arg processed: Indices of the already assembled rows; updated in place. :arg tol: Tolerance for detecting zeros in the numeric coefficients. """ - K = adjugate(_materialize_jacobian(J)).T + Jnp = _materialize_jacobian(J) + K = adjugate(Jnp).T + sd = K.shape[0] subgroups = {} for i, ell in group.items(): - if len(ell.points) != 1 or ell.rank != 1: + if len(ell.points) != 1: raise NotImplementedError( - "Only single-point vector evaluations are handled.") - subgroups.setdefault(ell.points, {})[i] = ell + "Only single-point evaluations are handled.") + subgroups.setdefault((ell.points, ell.rank), {})[i] = ell - for sub in subgroups.values(): + for (points, rank), sub in subgroups.items(): directions = numpy.array([ell.weights[0] for ell in sub.values()]) - if directions.shape[0] != directions.shape[1]: + # Projector onto the value space attainable at the point + tab = fiat_element.tabulate(0, points)[(0,) * sd] + u, s, vt = numpy.linalg.svd(tab.reshape(tab.shape[0], -1), + full_matrices=False) + vt = vt[s > tol * s[0]] + P = vt.T @ vt + # Coefficient extractor: project a raw mapped weight, then expand + # it in the projected group weights + E = numpy.linalg.pinv((directions @ P).T) @ P + # The group acts as its own completion only if its projected span + # is invariant under the slot map for every Jacobian; check with + # a generic matrix. + A = numpy.cos(numpy.multiply.outer(numpy.arange(1., sd + 1), + numpy.arange(2., 2 * sd + 2, 2))) + mapped = numpy.stack([ + _contract_slots(w.reshape((sd,) * rank), A).ravel() + for w in directions]) + residual = (mapped - (mapped @ E.T) @ directions) @ P + if not numpy.allclose(residual, 0, atol=tol * numpy.abs(mapped).max()): raise NotImplementedError( - "Directions do not span the vector components.") - Dinv = numpy.linalg.inv(directions.T) + "Node group does not span a slot-map invariant subspace.") + E[abs(E) < tol] = 0 for i, ell in sub.items(): - Kd = K @ ell.weights[0] + Kd = _contract_slots(ell.weights[0].reshape((sd,) * rank), K).ravel() for col, j in enumerate(sub): - x = Dinv[col] - nz = numpy.flatnonzero(abs(x) > tol) + x = E[col] + nz = numpy.flatnonzero(x) V[i, j] = reduce(add, (Kd[m] * x[m] for m in nz)) if len(nz) else Zero() processed.add(i) @@ -654,9 +710,35 @@ def _check_mapping(self, fiat_element): f"{type(self).__name__} expects a (double) contravariant " f"Piola pullback, not {mappings}.") + def dof_scale(self, node, dim: int, havg): + r"""Return the conditioning rescaling factor of one physical dof. + + On top of the default derivative-order convention, vertex point + evaluations of tensor-valued (rank-2) fields are redefined with a + factor :math:`h^{-2}`, following the hand-coded Arnold-Winther and + Hu-Zhang transformations; facet moments are already scaled by the + facet measure in FIAT and need no further rescaling. + + :arg node: The FIAT functional of the dof. + :arg dim: Topological dimension of the entity the dof sits on. + :arg havg: GEM scalar for the cell size averaged over the vertices + of the dof's entity. + :returns: The GEM scaling factor, or ``None`` for no rescaling. + """ + if dim == 0 and node.pt_dict: + comps = {comp for pt in node.pt_dict for w, comp in node.pt_dict[pt]} + if len(max(comps)) == 2: + return havg**(-2) + return super().dof_scale(node, dim, havg) + def invariant_dofs(self, group, dim, sd): - # Interior moments are Piola invariant by construction - return {i for i, ell in group.items() if ell.order == 0 and dim == sd} + # Interior *moments* are Piola invariant by construction: their + # physical test functions are themselves Piola-mapped. Single-point + # nodes are Cartesian point data even in the interior (e.g. the + # barycenter values of Guzman-Neilan second kind) and transform + # through the cofactor matrix instead. + return {i for i, ell in group.items() + if ell.order == 0 and dim == sd and len(ell.points) > 1} def facet_dof_rows(self, V: numpy.ndarray, group: dict, fiat_element: FiniteElement, entity: int, J: Node, @@ -690,14 +772,20 @@ def facet_dof_rows(self, V: numpy.ndarray, group: dict, group = self._divergence_rows(V, group, J, processed) if not group: return - if self._is_cartesian_point_group(group): - # Not a genuine facet moment (see _piola_point_rows): e.g. the - # edge dofs of AlfeldSorokina are plain Cartesian point values - # that happen to sit on a codimension-1 entity. - PiolaPhysicallyMappedElement._check_piola_group(group) - self._piola_point_rows(V, group, J, processed, tol) - return PiolaPhysicallyMappedElement._check_piola_group(group) + # Single-point rank-1 nodes are Cartesian point data, not facet + # moments (whose weights are genuine, usually multi-point quadrature + # profiles aligned with the facet frame), even when they sit on a + # codimension-1 entity: e.g. the edge midpoint values of + # AlfeldSorokina. Single-point rank-2 nodes remain frame dofs + # (e.g. the edge dofs of the Hu-Zhang point variant). + point_data = {i: ell for i, ell in group.items() + if len(ell.points) == 1 and ell.rank == 1} + if point_data: + self._piola_point_rows(V, point_data, fiat_element, J, processed, tol) + group = {i: ell for i, ell in group.items() if i not in point_data} + if not group: + return sd = J.shape[0] frame = PiolaFacetFrame(fiat_element, entity, J) nhat = frame.normal @@ -721,77 +809,87 @@ def facet_dof_rows(self, V: numpy.ndarray, group: dict, group = {i: ell for i, ell in group.items() if i not in processed} if not group: return - rank, = {ell.rank for ell in group.values()} - points, = {ell.points for ell in group.values()} - - # Numeric matching of the tangential coordinate profiles in the group. - # B has full row rank by unisolvence, so the Gram matrix B @ B.T is - # square and invertible; a rank deficiency (a genuine bug) surfaces - # as a LinAlgError here rather than a silent least-squares fit. - B = numpy.array([coords[j][:, 1:].ravel() for j in group]) - Binv = numpy.linalg.inv(B @ B.T) @ B - Binv[abs(Binv) < tol] = 0 - - # Numeric elimination of the residual normal profile against every - # basis function of the element (not just this facet's own normal - # dofs, since a completing dof may live on a different point set, - # e.g. Guzman-Neilan's mixed-order facet dofs). The quadrature-point - # axis is contracted purely numerically here, one reference-frame - # multi-index at a time, so that whether a coupling is present is - # decided from a plain numeric array (exact zero where a profile has - # no support at these points) rather than by inspecting the type of - # a symbolic GEM expression -- a sum of symbolic terms that is - # identically zero for all J need not reduce to a literal - # gem.Zero() node, so testing that structurally would either miss - # real cancellations or (as here) spuriously keep terms whose - # numeric coefficient actually vanishes. - ndir = numpy.ones(()) - for _ in range(rank): - ndir = numpy.multiply.outer(ndir, nhat) - T = fiat_element.tabulate(0, points)[(0,) * sd] - L = numpy.einsum("jcq,c->jq", T.reshape(T.shape[0], -1, len(points)), - ndir.ravel()) - L[abs(L) < tol] = 0 - # Lmap[i][m, r] = sum_q L[m, q] * coords[i][q, r]: numeric coupling - # of every basis function to each frame coordinate of node i's own - # profile, contracted over the shared quadrature points. - Lmap = {i: L @ coords[i] for i in group} - for i in Lmap: - Lmap[i][abs(Lmap[i]) < tol] = 0 - + # Nodes on different point sets or of different value rank (e.g. the + # mixed-degree edge moments of the Hu-Zhang integral variant) match + # their tangential profiles within their own subgroup; a genuine + # cross-subgroup coupling would surface below as a completion + # against an unprocessed node. + subgroups = {} for i, ell in group.items(): - # Pull back the coordinate profile, contracting each slot with Y - P = coords[i].reshape(-1, *(sd,) * rank) + subgroups.setdefault((ell.points, ell.rank), {})[i] = ell + + for (points, rank), sub in subgroups.items(): + # Numeric matching of the tangential coordinate profiles in the + # subgroup. B has full row rank by unisolvence, so the Gram + # matrix B @ B.T is square and invertible; a rank deficiency (a + # genuine bug) surfaces as a LinAlgError here rather than a + # silent least-squares fit. + B = numpy.array([coords[j][:, 1:].ravel() for j in sub]) + Binv = numpy.linalg.inv(B @ B.T) @ B + Binv[abs(Binv) < tol] = 0 + + # Numeric elimination of the residual normal profile against + # every basis function of the element (not just this facet's own + # normal dofs, since a completing dof may live on a different + # point set, e.g. Guzman-Neilan's mixed-order facet dofs). The + # quadrature-point axis is contracted purely numerically here, + # one reference-frame multi-index at a time, so that whether a + # coupling is present is decided from a plain numeric array + # (exact zero where a profile has no support at these points) + # rather than by inspecting the type of a symbolic GEM + # expression -- a sum of symbolic terms that is identically zero + # for all J need not reduce to a literal gem.Zero() node, so + # testing that structurally would either miss real cancellations + # or (as here) spuriously keep terms whose numeric coefficient + # actually vanishes. + ndir = numpy.ones(()) for _ in range(rank): - P = numpy.tensordot(P, Y, axes=(1, 1)) - P = P.reshape(len(points), -1) - - c = Binv @ P[:, 1:].ravel() - V[i, list(group)] = c - - # Couple the residual normal profile to every basis function, - # one reference-frame multi-index (or group member) at a time: - # each numeric column of Lmap decides its own sparsity, and the - # small symbolic frame-mixing coefficient only scales terms - # that already survived the numeric test. - terms = [] - for index in numpy.ndindex(*(sd,) * rank): - flat = numpy.ravel_multi_index(index, (sd,) * rank) - coef = Literal(1.0) - for r in index: - coef = coef * Y[0, r] - terms.append((Lmap[i][:, flat], coef)) - for k, j in enumerate(group): - terms.append((-Lmap[j][:, 0], c[k])) - - for vec, coef in terms: - for m in numpy.flatnonzero(vec): - if m not in processed: - raise NotImplementedError( - f"Completion of node {i} couples to node {m}, " - "which has not been transformed yet.") - V[i] += V[m] * (vec[m] * coef) - processed.add(i) + ndir = numpy.multiply.outer(ndir, nhat) + T = fiat_element.tabulate(0, points)[(0,) * sd] + L = numpy.einsum("jcq,c->jq", T.reshape(T.shape[0], -1, len(points)), + ndir.ravel()) + L[abs(L) < tol] = 0 + # Lmap[i][m, r] = sum_q L[m, q] * coords[i][q, r]: numeric + # coupling of every basis function to each frame coordinate of + # node i's own profile, contracted over the shared quadrature + # points. + Lmap = {i: L @ coords[i] for i in sub} + for i in Lmap: + Lmap[i][abs(Lmap[i]) < tol] = 0 + + for i, ell in sub.items(): + # Pull back the coordinate profile, contracting each slot with Y + P = coords[i].reshape(-1, *(sd,) * rank) + for _ in range(rank): + P = numpy.tensordot(P, Y, axes=(1, 1)) + P = P.reshape(len(points), -1) + + c = Binv @ P[:, 1:].ravel() + V[i, list(sub)] = c + + # Couple the residual normal profile to every basis function, + # one reference-frame multi-index (or subgroup member) at a + # time: each numeric column of Lmap decides its own sparsity, + # and the small symbolic frame-mixing coefficient only scales + # terms that already survived the numeric test. + terms = [] + for index in numpy.ndindex(*(sd,) * rank): + flat = numpy.ravel_multi_index(index, (sd,) * rank) + coef = Literal(1.0) + for r in index: + coef = coef * Y[0, r] + terms.append((Lmap[i][:, flat], coef)) + for k, j in enumerate(sub): + terms.append((-Lmap[j][:, 0], c[k])) + + for vec, coef in terms: + for m in numpy.flatnonzero(vec): + if m not in processed: + raise NotImplementedError( + f"Completion of node {i} couples to node {m}, " + "which has not been transformed yet.") + V[i] += V[m] * (vec[m] * coef) + processed.add(i) def point_dof_rows(self, V: numpy.ndarray, group: dict, fiat_element: FiniteElement, entity: int, J: Node, @@ -815,4 +913,4 @@ def point_dof_rows(self, V: numpy.ndarray, group: dict, if not group: return PiolaPhysicallyMappedElement._check_piola_group(group) - self._piola_point_rows(V, group, J, processed, tol) + self._piola_point_rows(V, group, fiat_element, J, processed, tol) diff --git a/test/finat/test_claude_zany_piola.py b/test/finat/test_claude_zany_piola.py index 3ae855880..1c65664a5 100644 --- a/test/finat/test_claude_zany_piola.py +++ b/test/finat/test_claude_zany_piola.py @@ -12,8 +12,9 @@ .. math:: B_{ij} = F_*(n_i)(\hat\psi_j), \qquad V = B^{-1}, -where the physical node :math:`n_i` is defined from the reference node by -the FIAT frame conventions: +where :math:`\hat\psi_j` is the reference nodal basis and the physical node +:math:`n_i` is defined from the reference node by the FIAT frame +conventions: * facet moments: normal profiles against the physical scaled normal :math:`K\hat\nu^s` (invariant, by the exact covariance of @@ -31,18 +32,32 @@ * divergence nodes: the value and derivative slot maps contract to :math:`\delta/\det J`, so the row is :math:`\det J` times the identity. -This module asserts that :math:`B^{-1}` agrees with -``basis_transformation`` to machine precision for the Piola element zoo, -in 2D and 3D, on cells of both orientations -- including hand-coded -elements not yet handled by :class:`finat.zany.PiolaPhysicallyMappedElement` -(Guzman-Neilan second kind, Hu-Zhang). See ``zany_claude.md`` (Stage 4) +Crucially, :math:`B` is never inverted densely. Its rows obey the support +law :math:`B_{ij} \ne 0` only if dof :math:`j` lives on the closure of dof +:math:`i`'s entity: interior-moment, divergence, and single-point rows are +*exactly* block-diagonal (pointwise identities, independent of the +polynomial space), while a facet row couples to its own facet block plus, +possibly, dofs on the boundary of that facet -- the residual left by the +frame decomposition is a functional of the trace on the facet, and the +trace is determined by the closure dofs (the same property that makes the +element conforming; in the scalar theory this role is played by the +fundamental-theorem-of-calculus exactness identities). Hence :math:`B` is +block lower triangular in the entity partial order and +:func:`composition_transformation` computes :math:`V = B^{-1}` by block +back-substitution -- the Piola instance of the scalar row recursion -- with +one small solve per entity and fill-in confined to the closure. Both the +support law and the agreement with ``basis_transformation`` are asserted +to machine precision for the Piola element zoo, in 2D and 3D, on cells of +both orientations. Since the whole zoo is now transformed by +:class:`finat.zany.PiolaPhysicallyMappedElement`, this module is the +independent verification of the automated transformations: it shares no +code path with ``basis_transformation``. See ``zany_claude.md`` (Stage 4) for the derivation. """ import numpy as np import pytest -import FIAT import finat from FIAT.reference_element import make_affine_mapping, ufc_simplex from finat.functional import PhysicallyMappedFunctional @@ -111,11 +126,45 @@ def facet_frame_net(ref_el, entity, J): return Ghat @ N @ np.linalg.inv(Ghat) -def composition_transformation(fiat_element, J): - """Compute V for a contravariant Piola element by duality alone. +def closure_dofs(fiat_element, dim, entity): + """Indices of the dofs on the strict closure of a cell entity. + + :arg fiat_element: The FIAT element. + :arg dim: The dimension of the entity. + :arg entity: The entity number. + :returns: The indices of the dofs supported on subentities of strictly + lower dimension contained in the closure of the entity. + """ + top = fiat_element.get_reference_element().get_topology() + entity_ids = fiat_element.entity_dofs() + verts = set(top[dim][entity]) + return [i for d in sorted(entity_ids) if d < dim + for e in entity_ids[d] if set(top[d][e]) <= verts + for i in entity_ids[d][e]] + + +def composition_transformation(fiat_element, J, ndof=None, tol=1e-10): + """Compute V for a contravariant Piola element by blockwise duality. + + The matrix :math:`B` of the module docstring is block lower triangular + in the entity partial order, so :math:`V = B^{-1}` is computed by block + back-substitution over the entities in order of increasing dimension: + + .. math:: V_e = B_{ee}^{-1} (I_e - B_{ec} V_c), + + where :math:`e` collects the dofs of one entity and :math:`c` the + (already processed) dofs on its strict closure. The support law that + justifies the triangular structure is asserted, not assumed: every row + of :math:`B` must vanish outside its own entity block and closure. + Fill-in is confined to the closure, so the sparsity of :math:`V` + matches that of the row recursion in the scalar theory. :arg fiat_element: The FIAT element on the reference cell. :arg J: The (numeric) cell Jacobian. + :arg ndof: The number of exposed physical dofs; unparseable constraint + functionals beyond it keep identity rows (their columns are + truncated by the caller). Defaults to all dofs. + :arg tol: Relative tolerance for the support-law assertion. :returns: The transformation V as a numpy array, with the same row and column ordering as ``basis_transformation`` before truncation of the trailing constraint columns. @@ -126,17 +175,37 @@ def composition_transformation(fiat_element, J): Theta_T = J.T / detJ nodes = fiat_element.dual_basis() entity_ids = fiat_element.entity_dofs() - B = np.eye(len(nodes)) - for dim in entity_ids: + nbf = len(nodes) + if ndof is None: + ndof = nbf + eye = np.eye(nbf) + V = np.zeros((nbf, nbf)) + done = set() + for dim in sorted(entity_ids): for entity in entity_ids[dim]: - for i in entity_ids[dim][entity]: - ell = PhysicallyMappedFunctional.from_fiat(nodes[i]) + # FIAT may list a dof on more than one entity (e.g. the edge + # moments of Arnold-Winther reappear in its interior list); the + # lowest-dimensional entity owns the dof. + block = [i for i in entity_ids[dim][entity] if i not in done] + if not block: + continue + done.update(block) + rows = [] + for i in block: + try: + ell = PhysicallyMappedFunctional.from_fiat(nodes[i]) + except NotImplementedError: + if i < ndof: + raise + rows.append(eye[i]) + continue if ell.divergence: - B[i] = evaluate_divergence(ell, fiat_element) / detJ + rows.append(evaluate_divergence(ell, fiat_element) / detJ) continue point_data = (len(ell.points) == 1 and (ell.rank == 1 or dim != sd - 1)) if dim == sd and not point_data: + rows.append(eye[i]) continue if dim == sd - 1 and not point_data: net = facet_frame_net(ref_el, entity, J) @@ -146,15 +215,25 @@ def composition_transformation(fiat_element, J): for _ in range(ell.rank): W = np.tensordot(W, net, axes=(1, 1)) W = W.reshape(len(ell.points), -1) - B[i] = PhysicallyMappedFunctional( - ell.points, W, rank=ell.rank).evaluate(fiat_element) - return np.linalg.inv(B) + rows.append(PhysicallyMappedFunctional( + ell.points, W, rank=ell.rank).evaluate(fiat_element)) + Bblock = np.asarray(rows) + prior = closure_dofs(fiat_element, dim, entity) + outside = np.setdiff1d(np.arange(nbf), block + prior) + if outside.size: + assert (np.abs(Bblock[:, outside]).max() + <= tol * np.abs(Bblock).max()), \ + f"support law violated on entity ({dim}, {entity})" + V[block] = np.linalg.solve( + Bblock[:, block], eye[block] - Bblock[:, prior] @ V[prior]) + return V piola_zoo = { 2: [(finat.MardalTaiWinther, ()), (finat.JohnsonMercier, ()), (finat.ArnoldWintherNC, ()), + (finat.ArnoldWinther, ()), (finat.AlfeldSorokina, ()), (finat.BernardiRaugel, ()), (finat.BernardiRaugelBubble, ()), @@ -205,6 +284,6 @@ def test_piola_composition(dimension, element, args, orientation): mapping = MyMapping(ref_cell, phys_cell) M = evaluate([finat_element.basis_transformation(mapping)])[0].arr - V = composition_transformation(finat_element._element, J) - V = V[:, :finat_element.space_dimension()] - assert np.allclose(V, M.T, atol=1e-10) + ndof = finat_element.space_dimension() + V = composition_transformation(finat_element._element, J, ndof=ndof) + assert np.allclose(V[:, :ndof], M.T, atol=1e-10) diff --git a/zany_claude.md b/zany_claude.md index 5261a83a6..a9e2d944a 100644 --- a/zany_claude.md +++ b/zany_claude.md @@ -665,7 +665,7 @@ and AW/AWnc once `from_fiat` learns tensor-divergence moments. Covariant (Nedele -type zany) elements: theory ready, value slot $\equiv$ derivative slot. Verification artifacts: the prototype now lives in the repo as -`test/finat/test_zany_composition.py`, upgraded from the in-session scripts: +`test/finat/test_claude_zany_piola.py`, upgraded from the in-session scripts: it computes $V = B^{-1}$ by *block back-substitution* (no dense inversion anywhere) and asserts both the support law of $B$ (see the Stage 5 design below) and agreement with `basis_transformation`, for the whole zoo, 2D and @@ -723,7 +723,7 @@ because the closure relation is *transitive* — the predicted fill equals the final fill, nothing grows during the elimination. Depth of the recursion $\le sd$ (vertex → edge → face → interior). At no point is a dense matrix formed or inverted; the prototype's `np.linalg.inv` is gone -(`composition_transformation` in `test/finat/test_zany_composition.py` now +(`composition_transformation` in `test/finat/test_claude_zany_piola.py` now implements exactly this loop and asserts the support law en route). **3. Diagonal blocks invert in closed form (the symbolic cost).** The only @@ -781,7 +781,7 @@ builds a symbolic physical Gram $G$ and its determinant for `Sinv`. vanishes outside the closure block (reference-time, once per element), so a non-conforming dof layout fails loudly instead of producing a dense or wrong $V$ (the generalization of Algorithm 1's assert line). -5. Tests: keep `test_zany_composition.py` as the independent numeric ground +5. Tests: keep `test_claude_zany_piola.py` as the independent numeric ground truth (it shares no code path with `basis_transformation`); add reflected-cell fixtures to `test_zany_mapping.py`. From 0fd3c30f09444ff44d3daab4bed641b7f0648308 Mon Sep 17 00:00:00 2001 From: Pablo Brubeck Date: Sat, 18 Jul 2026 10:53:00 +0100 Subject: [PATCH 31/34] Work on prototype --- test/finat/test_claude_zany_piola.py | 413 +++++++++++++++++++-------- 1 file changed, 295 insertions(+), 118 deletions(-) diff --git a/test/finat/test_claude_zany_piola.py b/test/finat/test_claude_zany_piola.py index 1c65664a5..db6b1503f 100644 --- a/test/finat/test_claude_zany_piola.py +++ b/test/finat/test_claude_zany_piola.py @@ -1,64 +1,70 @@ -r"""Numeric prototype of the unified transformation theory for Piola elements. - -On an affine cell, every Piola pullback factors through the componentwise -affine pullback: :math:`F^*_{piola} = \theta_A \circ F^*_{affine}`, with -:math:`\theta_A` pointwise multiplication of the value components by a -constant matrix. Dually, the push-forward of a degree of freedom acts -slot-locally on its weight tensor: each contravariant value slot is -contracted with :math:`\Theta^T = J^T/\det J = K^{-1}` (:math:`K` the -cofactor matrix of :math:`J`), and each derivative slot with :math:`J^{-1}`, -exactly as in the scalar theory. The matrix :math:`V` relating reference -nodes to push-forwards of physical nodes is then obtained by duality alone: - -.. math:: B_{ij} = F_*(n_i)(\hat\psi_j), \qquad V = B^{-1}, - -where :math:`\hat\psi_j` is the reference nodal basis and the physical node -:math:`n_i` is defined from the reference node by the FIAT frame -conventions: - -* facet moments: normal profiles against the physical scaled normal - :math:`K\hat\nu^s` (invariant, by the exact covariance of - ``compute_scaled_normal``), tangential profiles against the mapped - tangents :math:`J\hat{t}` in 2D, and against cross products - :math:`\nu^s \times J\hat{b}` of the scaled normal with mapped in-plane - vectors in 3D, whose push-forward has the closed form implemented in - :func:`facet_frame_net` (a *scalar* tangential block -- the ``Y`` mixing - matrix and ``Sinv`` reciprocal-basis correction of - :class:`finat.zany.PiolaFacetFrame` are consequences of this formula); -* single-point evaluations (vertex, edge, or interior point data): Cartesian - components kept, so the slot map is plain :math:`\Theta^T`; -* interior moments: invariant by convention (physical test functions are - Piola-mapped); -* divergence nodes: the value and derivative slot maps contract to - :math:`\delta/\det J`, so the row is :math:`\det J` times the identity. +r"""Numeric prototype of the unified transformation theory, scalar and Piola. + +The transformation matrix is obtained by duality alone: + +.. math:: B_{ij} = n_i(\hat\psi_j \circ F^{-1}), \qquad V = B^{-1}, + +where :math:`\hat\psi_j` is the reference nodal basis, :math:`F` the cell +map, and :math:`n_i` the physical node. Two ingredients make the rows of +:math:`B` computable without any frame algebra: + +* **Physical nodes by per-slot maps.** The physical node shares the + points and weights of its reference partner; only the directional data + changes, one tensor slot at a time. A derivative slot of a facet node + maps its unit-normal component to the unit physical normal + :math:`K\hat{n}/|K\hat{n}|` (:math:`K = \operatorname{adj}(J)^T` the + cofactor matrix, which maps normals to normals) and its tangential + complement by :math:`J` (mapped tangents); away from facets derivative + slots are Cartesian and keep their reference directions. A + contravariant value slot of a facet moment maps its scaled-normal + component by :math:`K` (the cofactor lemma :math:`K\hat\nu^s = \nu^s` + is exact) and its tangential complement by :math:`J`. Cartesian point + data keeps its weights, interior moments are invariant by convention + (physical test functions are Piola-mapped), and divergence nodes + contract to :math:`\det J` times the identity. + +* **The adjoint acts on the tabulation.** The push-forward of a + reference node contracts each derivative slot of its direction with + :math:`J` (:math:`d = J^{\otimes m}\hat{d}`); dually, instead of + transforming directions, each derivative slot of the *numeric* + reference tabulation is contracted with :math:`J^{-T}` (the physical + derivatives of :math:`\hat\psi_j\circ F^{-1}`) and each value slot + with :math:`J/\det J` (its physical Piola values), once per node + group. A row of :math:`B` is then the plain numeric pairing of the + physical node data with the transformed tabulation; push-forward + invariance (mapped-tangential moments, point values) needs no special + cases -- it falls out as exact Kronecker rows. Crucially, :math:`B` is never inverted densely. Its rows obey the support law :math:`B_{ij} \ne 0` only if dof :math:`j` lives on the closure of dof :math:`i`'s entity: interior-moment, divergence, and single-point rows are *exactly* block-diagonal (pointwise identities, independent of the -polynomial space), while a facet row couples to its own facet block plus, -possibly, dofs on the boundary of that facet -- the residual left by the -frame decomposition is a functional of the trace on the facet, and the -trace is determined by the closure dofs (the same property that makes the -element conforming; in the scalar theory this role is played by the -fundamental-theorem-of-calculus exactness identities). Hence :math:`B` is -block lower triangular in the entity partial order and -:func:`composition_transformation` computes :math:`V = B^{-1}` by block -back-substitution -- the Piola instance of the scalar row recursion -- with -one small solve per entity and fill-in confined to the closure. Both the -support law and the agreement with ``basis_transformation`` are asserted -to machine precision for the Piola element zoo, in 2D and 3D, on cells of -both orientations. Since the whole zoo is now transformed by -:class:`finat.zany.PiolaPhysicallyMappedElement`, this module is the -independent verification of the automated transformations: it shares no -code path with ``basis_transformation``. See ``zany_claude.md`` (Stage 4) -for the derivation. +polynomial space); a vertex-jet row is an exact combination of the +reference jet dofs at the same vertex (duality of the nodal basis); and a +facet row couples to its own facet block plus, possibly, dofs on the +boundary of that facet -- the residual left by the tangential components +is a functional of the trace on the facet, and the trace is determined by +the closure dofs (the same property that makes the element conforming; in +the scalar theory this role is played by the fundamental-theorem-of- +calculus exactness identities). Hence :math:`B` is block lower triangular +in the entity partial order and :func:`composition_transformation` +computes :math:`V = B^{-1}` by block back-substitution -- one small solve +per entity, fill-in confined to the closure. Both the support law and the +agreement with ``basis_transformation`` are asserted to machine precision +for the scalar and Piola element zoos, in 2D and 3D, on cells of both +orientations. This module is the independent verification of the +automated transformations: it shares no code path with +``basis_transformation``. See ``zany_claude.md`` (Stages 4-5) for the +derivation. """ +from math import factorial, prod + import numpy as np import pytest import finat +from FIAT.polynomial_set import mis from FIAT.reference_element import make_affine_mapping, ufc_simplex from finat.functional import PhysicallyMappedFunctional from gem.interpreter import evaluate @@ -66,6 +72,158 @@ from .conftest import MyMapping +def contract_slot(T, A, axis): + """Contract one tensor slot with a matrix. + + :arg T: The tensor. + :arg A: The matrix. + :arg axis: The slot of ``T`` to contract. + :returns: The tensor with ``T'[..., i, ...] = sum_k A[i, k] T[..., k, ...]``. + """ + return np.moveaxis(np.tensordot(T, A, axes=(axis, 1)), -1, axis) + + +def tensorize_direction(direction, sd, order): + """Distribute multi-index direction coefficients over a symmetric tensor. + + :arg direction: Direction coefficients in the multi-index basis. + :arg sd: The spatial dimension. + :arg order: The derivative order. + :returns: The symmetric direction tensor, normalized so that the full + contraction with an index-wise derivative tabulation reproduces + the multi-index pairing. + """ + alphas = sorted(mis(sd, order), reverse=True) + lookup = {alpha: k for k, alpha in enumerate(alphas)} + D = np.zeros((sd,) * order) + for index in np.ndindex(D.shape): + alpha = [0] * sd + for k in index: + alpha[k] += 1 + scale = prod(map(factorial, alpha)) / factorial(order) + D[index] = direction[lookup[tuple(alpha)]] * scale + return D + + +def derivative_tabulation(fiat_element, order, points, weights): + """Weighted symmetric-tensor derivative tabulation of the nodal basis. + + :arg fiat_element: The FIAT element. + :arg order: The derivative order. + :arg points: The quadrature points. + :arg weights: The quadrature weights. + :returns: The tensor ``T[j, i1, ..., im] = sum_q w_q + (d^m psi_j / dx_i1 ... dx_im)(x_q)``. + """ + sd = fiat_element.get_reference_element().get_spatial_dimension() + tab = fiat_element.tabulate(order, points) + nbf = tab[(0,) * sd].shape[0] + T = np.zeros((nbf,) + (sd,) * order) + for index in np.ndindex((sd,) * order): + alpha = [0] * sd + for k in index: + alpha[k] += 1 + T[(slice(None), *index)] = tab[tuple(alpha)] @ weights + return T + + +def facet_slot_map(ref_el, entity, J, derivative): + r"""Per-slot physical direction map of a facet node. + + A derivative slot maps its component along the FIAT normal + :math:`\hat{n}` to the FIAT physical normal -- the norm-preserving + rescaling of the cofactor image :math:`K\hat{n}` (:math:`K = + \operatorname{adj}(J)^T` maps normals to normals, and the norm of + the FIAT normal depends only on the reference cell) -- and its + tangential complement by :math:`J` (mapped tangents). + + A contravariant value slot maps its component along the scaled + normal :math:`\hat\nu^s` by :math:`K` (the cofactor lemma + :math:`K\hat\nu^s = \nu^s` is exact), and its tangential complement + to the cofactor image projected onto the physical facet, scaled by + :math:`|K\hat\nu^s|^2/(\det J\, |\hat\nu^s|^2)` -- the reciprocal + of the scalar tangential push-forward coefficient, which in 2D + reduces to the mapped tangent :math:`J\hat{t}`. + + :arg ref_el: The reference cell. + :arg entity: The facet number. + :arg J: The (numeric) cell Jacobian. + :arg derivative: If True, use the derivative-slot convention; + if False, the contravariant value-slot convention. + :returns: The map as an ``(sd, sd)`` array acting on one slot. + """ + sd = ref_el.get_spatial_dimension() + detJ = np.linalg.det(J) + K = detJ * np.linalg.inv(J).T + if derivative: + n = ref_el.compute_normal(entity) + Kn = K @ n + Kn = Kn * (np.linalg.norm(n) / np.linalg.norm(Kn)) + P = np.outer(n, n) / (n @ n) + return np.outer(Kn, n) / (n @ n) + J @ (np.eye(sd) - P) + n = ref_el.compute_scaled_normal(entity) + Kn = K @ n + P = np.outer(n, n) / (n @ n) + Q = np.eye(sd) - np.outer(Kn, Kn) / (Kn @ Kn) + s = (Kn @ Kn) / (detJ * (n @ n)) + return np.outer(Kn, n) / (n @ n) + s * (Q @ K @ (np.eye(sd) - P)) + + +def scalar_derivative_row(fiat_element, ell, Phi, J): + r"""B row of a scalar derivative node. + + The physical direction is the per-slot map :math:`\Phi^{\otimes m}` + of the reference direction; the derivative slots of the tabulation + carry the adjoint of the push-forward, :math:`J^{-T}` per slot. + + :arg fiat_element: The FIAT element. + :arg ell: The parsed reference node. + :arg Phi: The per-slot physical direction map. + :arg J: The (numeric) cell Jacobian. + :returns: The vector of values of the physical node on the + pushed-forward nodal basis. + """ + sd = J.shape[0] + T = derivative_tabulation(fiat_element, ell.order, ell.points, ell.weights) + A = np.linalg.inv(J).T + D = tensorize_direction(ell.direction, sd, ell.order) + for slot in range(ell.order): + T = contract_slot(T, A, 1 + slot) + D = contract_slot(D, Phi, slot) + return np.tensordot(T, D, axes=(tuple(range(1, ell.order + 1)), + tuple(range(ell.order)))) + + +def piola_value_row(fiat_element, ell, Phi, J): + r"""B row of a contravariant value node. + + The value slots of the tabulation carry the physical Piola values, + :math:`J/\det J` per slot; the weights are mapped per slot by + ``Phi`` (facet moments) or kept Cartesian (point data). + + :arg fiat_element: The FIAT element. + :arg ell: The parsed reference node. + :arg Phi: The per-slot physical weight map, or None for Cartesian + point data. + :arg J: The (numeric) cell Jacobian. + :returns: The vector of values of the physical node on the + pushed-forward nodal basis. + """ + sd = J.shape[0] + r = ell.rank + npts = len(ell.points) + Theta = J / np.linalg.det(J) + T = fiat_element.tabulate(0, ell.points)[(0,) * sd] + T = T.reshape(T.shape[0], *(sd,) * r, npts) + W = ell.weights.reshape(npts, *(sd,) * r) + for slot in range(r): + T = contract_slot(T, Theta, 1 + slot) + if Phi is not None: + W = contract_slot(W, Phi, 1 + slot) + return np.tensordot(T, W, axes=((*range(1, r + 1), r + 1), + (*range(1, r + 1), 0))) + + def evaluate_divergence(ell, fiat_element): """Apply a divergence functional to the nodal basis of a FIAT element. @@ -81,49 +239,45 @@ def evaluate_divergence(ell, fiat_element): return div @ ell.weights -def facet_frame_net(ref_el, entity, J): - r"""Net Cartesian map taking a facet node's reference weights to the - weights of its pushed-forward physical partner. - - In frame coordinates :math:`[\hat\nu^s | \hat{t}_l]` the map is - - .. math:: - N = \begin{pmatrix} 1 & -\det J\, \hat{t}_l \cdot M^{-1}\hat\nu^s - / |\hat\nu^s|^2 \\ - 0 & s I \end{pmatrix}, - \qquad - s = \det J\, \frac{\hat\nu^s \cdot M^{-1}\hat\nu^s}{|\hat\nu^s|^2}, - \qquad M = J^T J, - - for the 3D cross-product tangential convention; in 2D the tangential - directions are the plain mapped tangents. The top-right entries are the - completion residuals: a pulled-back tangential profile leaves behind a - normal-direction functional, eliminated through the generalized - Vandermonde row exactly as in the scalar theory. +def physical_node_row(fiat_element, ell, dim, entity, J, avg): + """B row of one parseable node, or None if push-forward invariant. - :arg ref_el: The reference cell. - :arg entity: The facet number. + :arg fiat_element: The FIAT element. + :arg ell: The parsed reference node. + :arg dim: The dimension of the entity the node sits on. + :arg entity: The entity number. :arg J: The (numeric) cell Jacobian. - :returns: The net map as an (sd, sd) array acting on Cartesian weights. + :arg avg: If False, physical scalar facet moments are plain + integrals rather than the measure-intrinsic integral averages of + the reference weights, and the row is rescaled by the physical + facet measure. + :returns: The row, or None for a row of the identity. """ + ref_el = fiat_element.get_reference_element() sd = ref_el.get_spatial_dimension() - detJ = np.linalg.det(J) - nu = ref_el.compute_scaled_normal(entity) - tangents = ref_el.compute_tangents(sd - 1, entity) - Ghat = np.column_stack([nu, *tangents]) - if sd == 2: - K = detJ * np.linalg.inv(J).T - P = np.column_stack([K @ nu, J @ tangents[0]]) - return (J.T / detJ) @ P @ np.linalg.inv(Ghat) - M = J.T @ J - Minv_nu = np.linalg.solve(M, nu) - nn = nu @ nu - N = np.zeros((sd, sd)) - N[0, 0] = 1.0 - for l, t in enumerate(tangents): - N[l + 1, l + 1] = detJ * (nu @ Minv_nu) / nn - N[0, l + 1] = -detJ * (t @ Minv_nu) / nn - return Ghat @ N @ np.linalg.inv(Ghat) + if ell.divergence: + return evaluate_divergence(ell, fiat_element) / np.linalg.det(J) + if ell.rank: + point_data = len(ell.points) == 1 and (ell.rank == 1 or dim != sd - 1) + if dim == sd and not point_data: + return None + Phi = (facet_slot_map(ref_el, entity, J, derivative=False) + if dim == sd - 1 and not point_data else None) + return piola_value_row(fiat_element, ell, Phi, J) + if ell.order == 0: + return None + Phi = (facet_slot_map(ref_el, entity, J, derivative=True) + if dim == sd - 1 else np.eye(sd)) + row = scalar_derivative_row(fiat_element, ell, Phi, J) + if not avg and len(ell.points) > 1 and dim == sd - 1: + # The reference weights are measure-intrinsic (integral averages), + # so a plain physical integral carries the physical facet measure. + n = ref_el.compute_scaled_normal(entity) + K = np.linalg.det(J) * np.linalg.inv(J).T + measure = (ref_el.volume_of_subcomplex(sd - 1, entity) + * np.linalg.norm(K @ n) / np.linalg.norm(n)) + row = row * measure + return row def closure_dofs(fiat_element, dim, entity): @@ -143,8 +297,8 @@ def closure_dofs(fiat_element, dim, entity): for i in entity_ids[d][e]] -def composition_transformation(fiat_element, J, ndof=None, tol=1e-10): - """Compute V for a contravariant Piola element by blockwise duality. +def composition_transformation(fiat_element, J, ndof=None, avg=True, tol=1e-10): + """Compute V for a scalar or Piola element by blockwise duality. The matrix :math:`B` of the module docstring is block lower triangular in the entity partial order, so :math:`V = B^{-1}` is computed by block @@ -164,15 +318,12 @@ def composition_transformation(fiat_element, J, ndof=None, tol=1e-10): :arg ndof: The number of exposed physical dofs; unparseable constraint functionals beyond it keep identity rows (their columns are truncated by the caller). Defaults to all dofs. + :arg avg: Whether physical scalar facet moments are integral averages. :arg tol: Relative tolerance for the support-law assertion. :returns: The transformation V as a numpy array, with the same row and column ordering as ``basis_transformation`` before truncation of the trailing constraint columns. """ - ref_el = fiat_element.get_reference_element() - sd = ref_el.get_spatial_dimension() - detJ = np.linalg.det(J) - Theta_T = J.T / detJ nodes = fiat_element.dual_basis() entity_ids = fiat_element.entity_dofs() nbf = len(nodes) @@ -199,24 +350,8 @@ def composition_transformation(fiat_element, J, ndof=None, tol=1e-10): raise rows.append(eye[i]) continue - if ell.divergence: - rows.append(evaluate_divergence(ell, fiat_element) / detJ) - continue - point_data = (len(ell.points) == 1 - and (ell.rank == 1 or dim != sd - 1)) - if dim == sd and not point_data: - rows.append(eye[i]) - continue - if dim == sd - 1 and not point_data: - net = facet_frame_net(ref_el, entity, J) - else: - net = Theta_T - W = ell.weights.reshape(-1, *(sd,) * ell.rank) - for _ in range(ell.rank): - W = np.tensordot(W, net, axes=(1, 1)) - W = W.reshape(len(ell.points), -1) - rows.append(PhysicallyMappedFunctional( - ell.points, W, rank=ell.rank).evaluate(fiat_element)) + row = physical_node_row(fiat_element, ell, dim, entity, J, avg) + rows.append(eye[i] if row is None else row) Bblock = np.asarray(rows) prior = closure_dofs(fiat_element, dim, entity) outside = np.setdiff1d(np.arange(nbf), block + prior) @@ -229,6 +364,35 @@ def composition_transformation(fiat_element, J, ndof=None, tol=1e-10): return V +# Scalar elements, including hand-coded macroelement transformations +# (HCT, Powell-Sabin, Alfeld/Bramble-Zlamal C2) never before reproduced by +# the automated theory. BrambleZlamalC2 needs a looser tolerance: FIAT's +# own dual basis and tabulation only agree to ~4e-10 on its ill-conditioned +# order-4 vertex jets. Walkington is excluded: its extended tabulation has +# full rank, so the constraint columns of the transformation are not a +# convention, and the hand-coded transformation disagrees there both with +# this prototype and with a direct physical-cell fit (see zany_claude.md). +scalar_zoo = { + 2: [(finat.Morley, ()), + (finat.Hermite, ()), + (finat.Bell, ()), + (finat.Argyris, ()), + (finat.Argyris, (5, "point")), + (finat.Argyris, (6,)), + (finat.WuXuH3NC, ()), + (finat.WuXuRobustH3NC, ()), + (finat.HsiehCloughTocher, ()), + (finat.HsiehCloughTocher, (4,)), + (finat.ReducedHsiehCloughTocher, ()), + (finat.QuadraticPowellSabin6, ()), + (finat.QuadraticPowellSabin12, ()), + (finat.AlfeldC2, ()), + (finat.BrambleZlamalC2, (), 2e-9), + ], + 3: [(finat.Morley, ()), + (finat.Hermite, ())], +} + piola_zoo = { 2: [(finat.MardalTaiWinther, ()), (finat.JohnsonMercier, ()), @@ -271,10 +435,8 @@ def composition_transformation(fiat_element, J, ndof=None, tol=1e-10): } -@pytest.mark.parametrize("orientation", ["positive", "negative"]) -@pytest.mark.parametrize("dimension, element, args", [ - (dim, *case) for dim in piola_zoo for case in piola_zoo[dim]]) -def test_piola_composition(dimension, element, args, orientation): +def check_composition(dimension, element, args, orientation): + """Compare the blockwise duality V against basis_transformation.""" ref_cell = ufc_simplex(dimension) phys_cell = ufc_simplex(dimension) phys_cell.vertices = orientations[(dimension, orientation)] @@ -285,5 +447,20 @@ def test_piola_composition(dimension, element, args, orientation): M = evaluate([finat_element.basis_transformation(mapping)])[0].arr ndof = finat_element.space_dimension() - V = composition_transformation(finat_element._element, J, ndof=ndof) + avg = getattr(finat_element, "avg", True) + V = composition_transformation(finat_element._element, J, ndof=ndof, avg=avg) assert np.allclose(V[:, :ndof], M.T, atol=1e-10) + + +@pytest.mark.parametrize("orientation", ["positive", "negative"]) +@pytest.mark.parametrize("dimension, element, args", [ + (dim, *case) for dim in scalar_zoo for case in scalar_zoo[dim]]) +def test_scalar_composition(dimension, element, args, orientation): + check_composition(dimension, element, args, orientation) + + +@pytest.mark.parametrize("orientation", ["positive", "negative"]) +@pytest.mark.parametrize("dimension, element, args", [ + (dim, *case) for dim in piola_zoo for case in piola_zoo[dim]]) +def test_piola_composition(dimension, element, args, orientation): + check_composition(dimension, element, args, orientation) From a9bac996be719c4988bd77a79f0f47c6ca16b5c9 Mon Sep 17 00:00:00 2001 From: Pablo Brubeck Date: Sat, 18 Jul 2026 22:28:55 +0100 Subject: [PATCH 32/34] bump tolerance --- test/finat/test_claude_zany_piola.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/finat/test_claude_zany_piola.py b/test/finat/test_claude_zany_piola.py index db6b1503f..6220d6a6d 100644 --- a/test/finat/test_claude_zany_piola.py +++ b/test/finat/test_claude_zany_piola.py @@ -297,7 +297,7 @@ def closure_dofs(fiat_element, dim, entity): for i in entity_ids[d][e]] -def composition_transformation(fiat_element, J, ndof=None, avg=True, tol=1e-10): +def composition_transformation(fiat_element, J, ndof=None, avg=True, tol=2e-10): """Compute V for a scalar or Piola element by blockwise duality. The matrix :math:`B` of the module docstring is block lower triangular @@ -387,7 +387,7 @@ def composition_transformation(fiat_element, J, ndof=None, avg=True, tol=1e-10): (finat.QuadraticPowellSabin6, ()), (finat.QuadraticPowellSabin12, ()), (finat.AlfeldC2, ()), - (finat.BrambleZlamalC2, (), 2e-9), + (finat.BrambleZlamalC2, ()), ], 3: [(finat.Morley, ()), (finat.Hermite, ())], @@ -449,7 +449,7 @@ def check_composition(dimension, element, args, orientation): ndof = finat_element.space_dimension() avg = getattr(finat_element, "avg", True) V = composition_transformation(finat_element._element, J, ndof=ndof, avg=avg) - assert np.allclose(V[:, :ndof], M.T, atol=1e-10) + assert np.allclose(V[:, :ndof], M.T, atol=2e-10) @pytest.mark.parametrize("orientation", ["positive", "negative"]) From bad1bc42a283e78533b0f517cacd016cf933df70 Mon Sep 17 00:00:00 2001 From: Pablo Brubeck Date: Sun, 19 Jul 2026 11:28:24 +0100 Subject: [PATCH 33/34] New sparse elimination approach --- finat/functional.py | 63 +-- finat/physically_mapped.py | 623 +++++++++++++++++++++++++- finat/zany.py | 897 ++----------------------------------- zany_claude.md | 57 +++ 4 files changed, 737 insertions(+), 903 deletions(-) diff --git a/finat/functional.py b/finat/functional.py index 3d0a059c8..2fd755666 100644 --- a/finat/functional.py +++ b/finat/functional.py @@ -22,7 +22,9 @@ Brubeck & Kirby (2025). """ +from functools import reduce from math import factorial, prod +from operator import mul import numpy @@ -180,51 +182,58 @@ def with_direction(self, direction) -> "PhysicallyMappedFunctional": order=self.order, direction=direction, mapping=self.mapping) - def pullback(self, J: Node) -> "PhysicallyMappedFunctional": - r"""View this reference functional as acting on physical functions. + def pullback(self, A) -> "PhysicallyMappedFunctional": + r"""Contract each derivative slot of the direction with a matrix. By the chain rule, reference derivatives of a pullback are - physical derivatives contracted with the Jacobian, so the - direction tensor maps covariantly: each slot is contracted - with :math:`J`. + physical derivatives contracted with the Jacobian, so viewing + this reference functional as acting on physical functions + amounts to contracting each slot of the direction tensor with + the cell Jacobian; more general per-slot matrices arise when + the direction is instead mapped through a physical frame. Parameters ---------- - J : - GEM expression for the cell Jacobian. + A : + The per-slot matrix: a GEM expression of shape ``(sd, sd)``, + or an ``(sd, sd)`` array with numeric or GEM scalar entries. Returns ------- PhysicallyMappedFunctional - The functional with direction :math:`J \\otimes \\dots - \\otimes J : D`, acting on physical derivatives at the - images of the reference points. + The functional with direction :math:`A \\otimes \\dots + \\otimes A : D`, collapsed back onto multi-index + coefficients. """ if self.order == 0: return self - sd = J.shape[0] + if isinstance(A, Node): + sd = A.shape[0] + A = numpy.array([[A[i, k] for k in range(sd)] for i in range(sd)], + dtype=object) + A = numpy.asarray(A) + sd = A.shape[0] + symbolic = A.dtype == object alphas = multiindices(sd, self.order) lookup = {alpha: k for k, alpha in enumerate(alphas)} - # Distribute the multi-index coefficients over a symmetric tensor - T = numpy.zeros((sd,) * self.order) - for index in numpy.ndindex(T.shape): + direction = numpy.full(len(alphas), Zero() if symbolic else 0.0, + dtype=object if symbolic else float) + shape = (sd,) * self.order + for index in numpy.ndindex(shape): + # Distribute the multi-index coefficients over a symmetric tensor alpha = _index_alpha(index, sd) scale = prod(map(factorial, alpha)) / factorial(self.order) - T[index] = self.direction[lookup[alpha]] * scale - - # Contract each slot with the Jacobian - Jnp = numpy.array([[J[i, k] for k in range(sd)] for i in range(sd)], - dtype=object) - for _ in range(self.order): - T = numpy.tensordot(T, Jnp, axes=(0, 1)) - - # Collapse back onto multi-index coefficients - direction = numpy.full(len(alphas), Zero(), dtype=object) - for index in numpy.ndindex(T.shape): - k = lookup[_index_alpha(index, sd)] - direction[k] = direction[k] + T[index] + d = self.direction[lookup[alpha]] * scale + if not isinstance(d, Node) and d == 0: + continue + # Contract each slot with A, collapsing onto multi-indices + for target in numpy.ndindex(shape): + term = reduce(mul, (A[target[k], index[k]] + for k in range(self.order)), d) + m = lookup[_index_alpha(target, sd)] + direction[m] = direction[m] + term return self.with_direction(direction) def evaluate(self, fiat_element: FiniteElement) -> numpy.ndarray: diff --git a/finat/physically_mapped.py b/finat/physically_mapped.py index aef6f581d..ecea01b0f 100644 --- a/finat/physically_mapped.py +++ b/finat/physically_mapped.py @@ -1,10 +1,65 @@ +r"""Physically mapped elements and the automatic basis transformation. + +The transformation matrix of a physically mapped element is obtained by +duality alone (Kirby 2017, Brubeck & Kirby 2025). With +:math:`\hat\psi_j` the reference nodal basis, :math:`F` the cell map and +:math:`n_i` the physical node, + +.. math:: B_{ij} = n_i(\hat\psi_j \circ F^{-1}), \qquad V = B^{-1}, + +and the physical basis functions are :math:`M F^*(\hat\Psi)` with +:math:`M = V^T`. Two ingredients make the rows of :math:`B` computable +without frame algebra, for any element whose dual basis parses into +:class:`~finat.functional.PhysicallyMappedFunctional`: + +* **Physical nodes by per-slot maps.** The physical node shares the + points and weights of its reference partner; only the directional + data changes, one tensor slot at a time. A derivative slot of a + facet node maps its unit-normal component to the unit physical normal + :math:`K\hat{n}/|K\hat{n}|` (:math:`K = \operatorname{adj}(J)^T` the + cofactor matrix, which maps normals to normals) and its tangential + complement by :math:`J` (mapped tangents); away from facets + derivative slots keep their reference (Cartesian) directions. A + contravariant value slot of a facet moment maps its scaled-normal + component by :math:`K` (the cofactor lemma :math:`K\hat\nu^s = + \nu^s` is exact) and its tangential complement by the reciprocal of + the scalar tangential push-forward; Cartesian point data keeps its + weights, interior moments are invariant by convention, and + divergence nodes contract to :math:`\det J` times the identity. + +* **The adjoint acts on the tabulation.** Dually to the push-forward + law :math:`d = J^{\otimes m}\hat{d}`, each derivative slot of the + *numeric* reference tabulation carries :math:`J^{-T}` and each value + slot :math:`J/\det J`; transposing these onto the direction gives a + single effective per-slot matrix, so a row of :math:`B` is a numeric + generalized Vandermonde pairing with symbolic per-slot coefficients. + +Crucially, :math:`B` is never inverted densely, and its sparsity is +*inferred from numerical tabulations*: :math:`B` is assembled +numerically at a few generic sample Jacobians, entries that vanish at +every sample are structural zeros (the support law: dof :math:`j` +couples to dof :math:`i` only on the closure of :math:`i`'s entity, the +same property that makes the element conforming), and entries that +agree at every sample are constants, so push-forward-invariant rows +never touch symbolic algebra. The coupling graph is block +triangularized by its strongly connected components and :math:`V` is +computed by sparse block back-substitution, one small +adjugate/determinant solve per diagonal block, with fill-in confined to +the closures. +""" + from abc import ABCMeta, abstractmethod from collections.abc import Mapping +from functools import reduce +from operator import add, mul import gem import numpy +from FIAT.finite_element import FiniteElement + from finat.citations import cite +from finat.functional import PhysicallyMappedFunctional, multiindices class NeedsCoordinateMappingElement(metaclass=ABCMeta): @@ -67,28 +122,225 @@ class PhysicallyMappedElement(NeedsCoordinateMappingElement): """A mixin that applies a "physical" transformation to tabulated basis functions. - Concrete elements either implement :meth:`basis_transformation` - entirely by hand, or derive it automatically by mixing in - :class:`~finat.zany.ScalarPhysicallyMappedElement` or - :class:`~finat.zany.PiolaPhysicallyMappedElement`, which (via - :class:`~finat.zany.ZanyPhysicallyMappedElement`) supply the - entity-by-entity assembly loop of Kirby (2017) and - Brubeck & Kirby (2025). + :meth:`basis_transformation` derives the transformation generically + from the FIAT dual basis by the duality formulation of the module + docstring; the mixins of :mod:`finat.zany` supply the + family-specific conventions (expected pullback, moment + normalization, dof rescaling), and elements whose dual basis the + generic engine cannot parse override it entirely by hand. """ + #: Numerical tolerance used to drop negligible terms of the numeric + #: tabulations from the symbolic rows. + tol = 1e-12 + + #: Row-relative tolerance deciding, from the sample assemblies, which + #: entries of the transformation are structural zeros or constants. + pattern_tol = 1e-10 + + #: If False, physical scalar facet moments are plain integrals rather + #: than the measure-intrinsic integral averages of the reference nodes. + avg = True + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) cite("Kirby2018zany") cite("Kirby2019zany") self.restriction_indices = None - @abstractmethod - def basis_transformation(self, coordinate_mapping): - """Transformation matrix for the basis functions. + def basis_transformation(self, coordinate_mapping) -> gem.ListTensor: + r"""Transformation matrix for the basis functions. + + Assembles the generalized Vandermonde matrix :math:`B_{ij} = + n_i(\hat\psi_j\circ F^{-1})` and computes :math:`M = B^{-T}` + by sparse block back-substitution. The sparsity and the + constant entries of :math:`B` are inferred from numeric + assemblies at sample Jacobians; only the remaining entries are + built symbolically, in numerator/denominator form, and each + diagonal block of the strongly-connected-component + triangularization is solved through its adjugate. + + Parameters + ---------- + coordinate_mapping : + Object providing the physical geometry as GEM expressions. + + Returns + ------- + gem.ListTensor + The transformation matrix, shape ``(space_dimension(), + nbf)``, with the trailing constraint columns of an extended + element truncated. - :arg coordinate_mapping: Object providing physical geometry.""" + """ + fiat_element = self._element + self._check_mapping(fiat_element) + ref_el = fiat_element.get_reference_element() + sd = ref_el.get_spatial_dimension() + bary, = ref_el.make_points(sd, 0, sd + 1) + J = _materialize_jacobian(coordinate_mapping.jacobian_at(bary)) + + nodes = fiat_element.dual_basis() + mappings = fiat_element.mapping() + entity_ids = fiat_element.entity_dofs() + nbf = fiat_element.space_dimension() + ndof = self.space_dimension() + + # Parse the dual basis. FIAT may list a dof on more than one + # entity (e.g. the edge moments of Arnold-Winther reappear in its + # interior list); the lowest-dimensional entity owns the dof. + # Unparseable constraint functionals of an extended element are + # never exposed as physical dofs: their physical counterparts are + # defined as the pullbacks of the reference ones (Kirby 2017, + # section 5), keeping identity rows, and the corresponding + # columns are truncated below. + ells = {} + owners = {} + for dim in sorted(entity_ids): + for entity in sorted(entity_ids[dim]): + for i in entity_ids[dim][entity]: + if i in owners: + continue + owners[i] = (dim, entity) + try: + ells[i] = PhysicallyMappedFunctional.from_fiat( + nodes[i], mapping=mappings[i]) + except NotImplementedError: + if i < ndof: + raise + ells[i] = None + + # Infer the sparsity and the constant entries numerically + tabs = {} + samples = [self._assemble_numeric(fiat_element, ells, owners, Jk, tabs) + for Jk in _sample_jacobians(sd)] + pattern, constant, Bconst = _infer_pattern(samples, self.pattern_tol) + + # Build the non-constant rows symbolically, in + # numerator/denominator form, on their own pattern columns only + numer = numpy.empty(nbf, dtype=object) + denom = numpy.empty(nbf, dtype=object) + for i in range(nbf): + cols = numpy.flatnonzero(pattern[i]) + if ells[i] is None or constant[i, cols].all(): + numer[i] = numpy.array([_as_gem(v) for v in Bconst[i, cols]], + dtype=object) + denom[i] = one + else: + dim, entity = owners[i] + numer[i], denom[i] = _node_row( + fiat_element, ells[i], dim, entity, J, + self.avg, self.tol, tabs, cols=cols) + + # Sparse block back-substitution over the strongly connected + # components of the coupling graph, in dependency order: + # V_S = B_SS^{-1} (I_S - B_Sc V_c), with B = diag(1/denom) numer, + # so the denominators only enter the right-hand side. + V = numpy.full((nbf, nbf), zero, dtype=object) + inv_cache = {} + for block in _scc_blocks(pattern): + inside = set(block) + rhs = numpy.full((len(block), nbf), zero, dtype=object) + BSS = numpy.full((len(block), len(block)), zero, dtype=object) + index = {i: k for k, i in enumerate(block)} + for k, i in enumerate(block): + rhs[k, i] = denom[i] + for c, j in enumerate(numpy.flatnonzero(pattern[i])): + if j in inside: + BSS[k, index[j]] = numer[i][c] + elif not isinstance(numer[i][c], gem.Zero): + # numpy elementwise product: the object array must + # be the left operand, lest GEM broadcast a vector + rhs[k] = rhs[k] - V[j] * numer[i][c] + V[block] = _solve_block(BSS, rhs, inv_cache) + + self._rescale_dofs(V, fiat_element, coordinate_mapping) + return gem.ListTensor(V[:, :ndof].T) + + def _assemble_numeric(self, fiat_element: FiniteElement, ells: dict, + owners: dict, J: numpy.ndarray, tabs: dict) -> numpy.ndarray: + """Assemble B numerically at one sample Jacobian. + + :arg fiat_element: The FIAT element defined on the reference cell. + :arg ells: Map from dof index to parsed + :class:`~finat.functional.PhysicallyMappedFunctional`, or + None for a constraint functional keeping an identity row. + :arg owners: Map from dof index to its owning ``(dim, entity)``. + :arg J: Numeric sample Jacobian. + :arg tabs: Cache of reference tabulations, shared across samples. + :returns: The matrix B as a numpy array. + """ + B = numpy.eye(fiat_element.space_dimension()) + for i, ell in ells.items(): + if ell is None: + continue + dim, entity = owners[i] + res = _node_row(fiat_element, ell, dim, entity, J, + self.avg, self.tol, tabs) + if res is not None: + row, den = res + B[i] = row / den + return B + + def _check_mapping(self, fiat_element: FiniteElement) -> None: + """Verify that this class knows how to transform this element's pullback. + + The generic engine dispatches on the mapping of each dof; this + hook lets the family mixins of :mod:`finat.zany` reject FIAT + elements outside their conventions up front. + + :arg fiat_element: The FIAT element defined on the reference cell. + :raises NotImplementedError: If the pullback is not supported. + """ pass + def _rescale_dofs(self, V: numpy.ndarray, fiat_element: FiniteElement, + coordinate_mapping) -> None: + r"""Rescale the physical degrees of freedom by powers of the cell size. + + Each column of ``V`` is multiplied by :meth:`dof_scale` evaluated + with the cell size averaged over the vertices of the dof's entity. + This is the FInAT convention keeping the mass matrix + well-conditioned; it is consistent across cells because the + scaling only depends on shared entities. + + :arg V: Object array being assembled; columns are rescaled in place. + :arg fiat_element: The FIAT element defined on the reference cell. + :arg coordinate_mapping: Object providing the physical geometry as + GEM expressions. + """ + # cell_size may be a GEM expression or a numpy array of numbers + h = coordinate_mapping.cell_size() + top = fiat_element.get_reference_element().get_topology() + nodes = fiat_element.dual_basis() + entity_ids = fiat_element.entity_dofs() + for dim in entity_ids: + for entity in entity_ids[dim]: + verts = top[dim][entity] + havg = reduce(add, (h[v] for v in verts)) / len(verts) + for i in entity_ids[dim][entity]: + scale = self.dof_scale(nodes[i], dim, havg) + if scale is not None: + V[:, i] = V[:, i] * scale + + def dof_scale(self, node, dim: int, havg): + r"""Return the conditioning rescaling factor of one physical dof. + + The default convention redefines each physical node of derivative + order :math:`m > 0` with a factor :math:`h^{-m}`; elements whose + hand-coded transformations established a different convention + (e.g. the :math:`h^{-2}` vertex values of Hu-Zhang) override this + method. + + :arg node: The FIAT functional of the dof. + :arg dim: Topological dimension of the entity the dof sits on. + :arg havg: GEM scalar for the cell size averaged over the vertices + of the dof's entity. + :returns: The GEM scaling factor, or ``None`` for no rescaling. + """ + order = node.max_deriv_order + return havg**(-order) if order > 0 else None + def map_tabulation(self, ref_tabulation, coordinate_mapping): assert coordinate_mapping is not None M = self.basis_transformation(coordinate_mapping) @@ -286,3 +538,352 @@ def inverse(A): M[numpy.ix_(ids, ids)] = Minv return M + + +def _materialize_jacobian(J: gem.Node) -> numpy.ndarray: + """Materialize a GEM Jacobian node as a numpy object array of GEM scalars. + + :arg J: GEM expression for the cell Jacobian, shape ``(sd, sd)``. + :returns: A ``(sd, sd)`` numpy object array with the same entries as + ``J``, usable with the numeric-or-symbolic linear algebra of this + module (:func:`determinant`, :func:`adjugate`), which index a GEM + node's entries directly rather than through numpy fancy indexing. + """ + sd = J.shape[0] + return numpy.array([[J[i, k] for k in range(sd)] for i in range(sd)], dtype=object) + + +def _as_gem(v: float) -> gem.Node: + """Convert a number to a GEM constant, preserving structural zeros. + + :arg v: The number. + :returns: ``Zero`` for an exact zero, so that downstream products + and sums fold, the shared ``one`` for an exact one, and a + ``Literal`` otherwise. + """ + if v == 0: + return zero + if v == 1: + return one + return gem.Literal(v) + + +def _as_gem_array(A) -> numpy.ndarray: + """Convert a numeric array to an object array of GEM constants. + + :arg A: The numeric array. + :returns: An object array of the same shape, entrywise :func:`_as_gem`. + """ + A = numpy.asarray(A) + out = numpy.full(A.shape, zero, dtype=object) + for index in numpy.ndindex(A.shape): + out[index] = _as_gem(float(A[index])) + return out + + +def _sample_jacobians(sd: int, nsamples: int = 3) -> list: + """Deterministic generic sample Jacobians for sparsity inference. + + :arg sd: The spatial dimension. + :arg nsamples: How many samples; the last is orientation-reversing. + :returns: A list of well-conditioned ``(sd, sd)`` numeric Jacobians + with no special alignments, at which entries of the + transformation that vanish (or agree) at every sample may be + declared structurally zero (or constant). + """ + samples = [] + t = 0.0 + while len(samples) < nsamples: + t += 1.0 + A = numpy.eye(sd) + 0.4 * numpy.cos( + t + 7.3 * numpy.arange(1.0, sd * sd + 1.0)).reshape(sd, sd) + det = numpy.linalg.det(A) + if abs(det) < 0.3: + continue + if len(samples) == nsamples - 1 and det * numpy.linalg.det(samples[0]) > 0: + A = A[::-1] if sd > 1 else -A + samples.append(A) + return samples + + +def _infer_pattern(samples: list, tol: float) -> tuple: + """Infer the sparsity and the constant entries of B from samples. + + :arg samples: Numeric assemblies of B at the sample Jacobians. + :arg tol: Row-relative tolerance below which an entry is a + structural zero, and spread below which it is a constant. + :returns: A tuple ``(pattern, constant, Bconst)`` of the boolean + coupling pattern (with full diagonal), the boolean mask of + J-independent entries, and their values (averaged over the + samples, snapped to exact integers where they round). + """ + stack = numpy.stack(samples) + scale = numpy.abs(stack).max(axis=(0, 2)) + pattern = (numpy.abs(stack) > tol * scale[None, :, None]).any(axis=0) + numpy.fill_diagonal(pattern, True) + Bconst = stack.mean(axis=0) + spread = stack.max(axis=0) - stack.min(axis=0) + constant = spread <= tol * numpy.maximum(1.0, numpy.abs(Bconst)) + snapped = numpy.round(Bconst) + near = constant & (numpy.abs(Bconst - snapped) + <= tol * numpy.maximum(1.0, numpy.abs(Bconst))) + Bconst[near] = snapped[near] + return pattern, constant, Bconst + + +def _scc_blocks(pattern: numpy.ndarray) -> list: + """Strongly connected components of the coupling graph, in dependency order. + + Tarjan's algorithm on the digraph with an edge ``i -> j`` whenever + ``pattern[i, j]``: each emitted component only depends on (has + pattern entries in) previously emitted ones, so emission order is + elimination order, and the block triangular structure of B is a + consequence of the inferred sparsity rather than an assumption. + + :arg pattern: Boolean coupling pattern of B. + :returns: List of sorted lists of dof indices, one per component. + """ + n = pattern.shape[0] + adjacency = [numpy.flatnonzero(pattern[i]) for i in range(n)] + index = [-1] * n + low = [0] * n + onstack = [False] * n + stack = [] + blocks = [] + counter = [0] + + def strongconnect(v): + index[v] = low[v] = counter[0] + counter[0] += 1 + stack.append(v) + onstack[v] = True + for w in adjacency[v]: + if w == v: + continue + if index[w] == -1: + strongconnect(w) + low[v] = min(low[v], low[w]) + elif onstack[w]: + low[v] = min(low[v], index[w]) + if low[v] == index[v]: + component = [] + while True: + w = stack.pop() + onstack[w] = False + component.append(w) + if w == v: + break + blocks.append(sorted(component)) + + for v in range(n): + if index[v] == -1: + strongconnect(v) + return blocks + + +def _solve_block(B: numpy.ndarray, rhs: numpy.ndarray, cache: dict) -> numpy.ndarray: + """Solve one diagonal block through its adjugate. + + :arg B: Object array with the (GEM) block of B numerators. + :arg rhs: Object array with the right-hand side rows. + :arg cache: Cache of adjugate/determinant pairs, shared across + blocks so that identical blocks (e.g. the vertex jets of every + vertex) are only inverted once. + :returns: The rows of V for this block. + """ + if B.shape[0] == 1: + d = B[0, 0] + return rhs if d == one else rhs / d + key = gem.ListTensor(B) + try: + adj, det = cache[key] + except KeyError: + adj, det = cache.setdefault(key, (adjugate(B), determinant(B))) + return (adj @ rhs) / det + + +def _tabulate(fiat_element: FiniteElement, order: int, points: tuple, + tabs: dict) -> dict: + """Cached reference tabulation of the nodal basis. + + :arg fiat_element: The FIAT element. + :arg order: The derivative order. + :arg points: The points, as a hashable tuple. + :arg tabs: The cache. + :returns: The FIAT tabulation dict. + """ + key = (order, points) + try: + return tabs[key] + except KeyError: + return tabs.setdefault(key, fiat_element.tabulate(order, points)) + + +def _slot_map(ref_el, entity: int, J: numpy.ndarray, + derivative: bool, facet: bool) -> tuple: + r"""Effective per-slot matrix pairing a physical node with the reference tabulation. + + Transposing the per-slot action of the push-forward onto the + physical direction data gives a single matrix per slot: + :math:`G = J^{-1}\Phi` for a derivative slot with physical + direction map :math:`\Phi`, and :math:`G = (J/\det J)^T\Phi` for a + contravariant value slot with physical weight map :math:`\Phi`. + Away from a facet :math:`\Phi` is the identity (Cartesian data); + on a facet it maps the normal component by the cofactor law and the + tangential complement by the mapped tangents, as in the module + docstring. The map is returned in numerator/denominator form so + that the numerators stay free of symbolic division. + + :arg ref_el: The reference cell. + :arg entity: The facet number (ignored unless ``facet``). + :arg J: The cell Jacobian: numeric array, or object array of GEM scalars. + :arg derivative: Whether the slot is a derivative or a value slot. + :arg facet: Whether the node data is in the facet frame rather than + Cartesian. + :returns: The pair ``(numerator, denominator)`` with + ``G = numerator / denominator``. + """ + detJ = determinant(J) + adjJ = adjugate(J) + if not facet: + return (adjJ, detJ) if derivative else (J.T, detJ) + sd = ref_el.get_spatial_dimension() + symbolic = J.dtype == object + lit = _as_gem_array if symbolic else numpy.asarray + K = adjJ.T + if derivative: + n = ref_el.compute_normal(entity) + nn = n @ n + Kn = K @ n + q = (Kn @ Kn) ** 0.5 + Gnum = (numpy.outer(adjJ @ Kn, lit(n * (nn ** 0.5 / nn))) + + lit(numpy.eye(sd) - numpy.outer(n, n) / nn) * (q * detJ)) + return Gnum, detJ * q + nu = ref_el.compute_scaled_normal(entity) + nn = nu @ nu + Knu = K @ nu + qq = Knu @ Knu + IP = lit((numpy.eye(sd) - numpy.outer(nu, nu) / nn) / nn) + Gnum = (lit(numpy.outer(nu, nu) / nn) * (detJ * detJ) + + ((J.T @ K) * qq - numpy.outer(lit(nu), K.T @ Knu) * detJ) @ IP) + return Gnum, detJ * detJ + + +def _sparse_combination(coeffs, N: numpy.ndarray, tol: float, + symbolic: bool) -> numpy.ndarray: + """Combine numeric tabulation rows with (possibly symbolic) coefficients. + + Computes ``row[j] = sum_k coeffs[k] * N[k, j]``, dropping negligible + numeric entries so that whether a coupling is present is decided + from the numeric array, never by inspecting a symbolic expression. + + :arg coeffs: The coefficients, numeric or GEM scalars. + :arg N: The numeric tabulation pairing matrix. + :arg tol: Relative tolerance below which entries of ``N`` are dropped. + :arg symbolic: Whether to accumulate GEM expressions. + :returns: The combined row. + """ + row = (numpy.full(N.shape[1], zero, dtype=object) if symbolic + else numpy.zeros(N.shape[1])) + scale = numpy.abs(N).max() + if scale == 0: + return row + for k, c in enumerate(coeffs): + if isinstance(c, gem.Zero) or (not isinstance(c, gem.Node) and c == 0): + continue + for j in numpy.flatnonzero(numpy.abs(N[k]) > tol * scale): + row[j] = row[j] + c * N[k, j] + return row + + +def _node_row(fiat_element: FiniteElement, ell: PhysicallyMappedFunctional, + dim: int, entity: int, J: numpy.ndarray, avg: bool, tol: float, + tabs: dict, cols=None): + r"""One row of the generalized Vandermonde matrix B. + + The row is the pairing of the physical node data with the reference + tabulation carrying the adjoint of the push-forward, assembled with + the single effective per-slot matrix of :func:`_slot_map`; + divergence nodes commute with the Piola pullback up to + :math:`\det J`, and nodes whose physical counterpart is the + push-forward of the reference one by convention (point values, + interior moments) return None for an identity row. + + :arg fiat_element: The FIAT element. + :arg ell: The parsed reference node. + :arg dim: The dimension of the entity the node sits on. + :arg entity: The entity number. + :arg J: The cell Jacobian: numeric array, or object array of GEM scalars. + :arg avg: Whether physical scalar facet moments are integral averages. + :arg tol: Relative tolerance below which numeric couplings are dropped. + :arg cols: Optional column indices to restrict the row to. + :returns: The pair ``(numerator_row, denominator)`` with + ``B[i] = numerator_row / denominator``, or None for a row of + the identity. + """ + ref_el = fiat_element.get_reference_element() + sd = ref_el.get_spatial_dimension() + symbolic = J.dtype == object + if cols is None: + cols = slice(None) + + if ell.divergence: + tab = _tabulate(fiat_element, 1, ell.points, tabs) + alphas = [tuple(int(k == c) for k in range(sd)) for c in range(sd)] + div = sum(tab[alphas[c]].reshape(tab[alphas[c]].shape[0], sd, -1)[:, c, :] + for c in range(sd)) + row = div @ ell.weights + scale = numpy.abs(row).max() + row[numpy.abs(row) <= tol * scale] = 0.0 + row = row[cols] + if symbolic: + row = numpy.array([_as_gem(v) for v in row], dtype=object) + return row, determinant(J) + + if ell.rank: + if ell.mapping not in ("contravariant piola", "double contravariant piola"): + raise NotImplementedError( + f"Cannot transform value nodes with a {ell.mapping} pullback.") + if ell.order: + raise NotImplementedError( + "Cannot transform nodes mixing values and derivatives.") + npts = len(ell.points) + # Single-point rank-1 nodes are Cartesian point data, not facet + # moments, even when they sit on a codimension-1 entity (e.g. the + # edge midpoint values of Alfeld-Sorokina); single-point rank-2 + # nodes remain frame dofs (e.g. the edge dofs of the Hu-Zhang + # point variant). + point_data = npts == 1 and (ell.rank == 1 or dim != sd - 1) + if dim == sd and not point_data: + # Interior moments are Piola invariant by convention: their + # physical test functions are themselves Piola-mapped. + return None + facet = dim == sd - 1 and not point_data + Gnum, den = _slot_map(ref_el, entity, J, derivative=False, facet=facet) + tab = _tabulate(fiat_element, 0, ell.points, tabs)[(0,) * sd] + T = tab.reshape(tab.shape[0], sd**ell.rank, npts) + N = numpy.einsum("jpq,qc->pcj", T, ell.weights) + N = N.reshape(sd**ell.rank * sd**ell.rank, -1) + Gprod = reduce(numpy.kron, [Gnum] * ell.rank) + row = _sparse_combination(Gprod.ravel(), N[:, cols], tol, symbolic) + return row, reduce(mul, [den] * ell.rank) + + if ell.order == 0: + # Point values pull back exactly + return None + facet = dim == sd - 1 + Gnum, den = _slot_map(ref_el, entity, J, derivative=True, facet=facet) + direction = ell.pullback(Gnum).direction + tab = _tabulate(fiat_element, ell.order, ell.points, tabs) + N = numpy.stack([tab[alpha] @ ell.weights + for alpha in multiindices(sd, ell.order)]) + row = _sparse_combination(direction, N[:, cols], tol, symbolic) + if not avg and len(ell.points) > 1 and facet: + # The reference weights are measure-intrinsic (integral averages), + # so a plain physical integral carries the physical facet measure. + nu = ref_el.compute_scaled_normal(entity) + Knu = adjugate(J).T @ nu + measure = (ref_el.volume_of_subcomplex(sd - 1, entity) + / (nu @ nu) ** 0.5) * (Knu @ Knu) ** 0.5 + row = row * measure + return row, reduce(mul, [den] * ell.order) diff --git a/finat/zany.py b/finat/zany.py index f3aca4eb7..0e8ab5390 100644 --- a/finat/zany.py +++ b/finat/zany.py @@ -1,709 +1,60 @@ -r"""Automatic basis transformations for physically mapped elements. +r"""Family mixins for automatically transformed elements. -This module automates the transformation theory of Kirby (2017) and -Brubeck & Kirby (2025). Given a FIAT element whose degrees of freedom -are not preserved under push-forward, :class:`ZanyPhysicallyMappedElement` -constructs the matrix :math:`V` relating the reference nodes to the -push-forwards of the physical nodes, so that the physical basis -functions are obtained as :math:`M F^*(\hat\Psi)` with :math:`M = V^T`. - -Degrees of freedom are represented symbolically by -:class:`finat.functional.PhysicallyMappedFunctional` and processed -generically, without dispatching over FIAT functional types. -:class:`ZanyPhysicallyMappedElement` implements the entity-by-entity -assembly loop once, calling four hooks that carry all mapping-specific -knowledge; this module supplies the two mixins implementing them: +The basis transformation itself is derived generically by +:meth:`finat.physically_mapped.PhysicallyMappedElement.basis_transformation`, +which assembles the generalized Vandermonde matrix :math:`B_{ij} = +n_i(\hat\psi_j\circ F^{-1})` from the FIAT dual basis and inverts it by +sparse block back-substitution over its numerically inferred sparsity +pattern. The classes here only carry the family-specific conventions: * :class:`ScalarPhysicallyMappedElement`, for scalar elements with an - affine (identity) pullback -- Morley, Hermite, Argyris, Bell. Each - reference node is pulled back to the physical cell by the chain rule - and expanded in the frame of the physical facet normal and the mapped - reference tangents (:class:`FacetFrame`); the tangential components - are derivatives along *mapped* reference tangents and therefore - coincide with reference functionals, whose expansion in the element's - own nodes is a purely numeric generalized Vandermonde row. + affine (identity) pullback -- Morley, Hermite, Argyris, Bell. The + physical facet nodes take their normal component along the unit + physical normal and their tangential components along the mapped + reference tangents; the ``avg`` attribute records whether physical + facet moments are integral averages or plain integrals. * :class:`PiolaPhysicallyMappedElement`, for vector- or tensor-valued elements under the (double) contravariant Piola pullback -- - Mardal-Tai-Winther, Johnson-Mercier, Guzman-Neilan. The roles of the - normal and tangential directions are mirrored (:class:`PiolaFacetFrame`): - the scaled facet normal is the cofactor image of the reference one, so - pure normal-component moments are invariant, while scaled tangents map - by the Jacobian. - -In the language of the theory, the frame expansion in either mixin -realizes :math:`E V^c`, and the numeric elimination of the tangential -(respectively normal) completion realizes :math:`D`. - -**Glossary.** :math:`E`, :math:`V^c`, and :math:`D` are never materialized as -literal matrices; each is realized implicitly by a piece of the assembly: - -* :math:`V` -- the object array assembled in place by - :meth:`ZanyPhysicallyMappedElement.basis_transformation`; row = reference - node, column = physical node (see ``gem/AGENTS.md``). Starts as the - identity (:func:`~finat.physically_mapped.identity`), which *is* the - :math:`E V^c D` factorization for push-forward-invariant nodes. -* :math:`E` -- realized by keeping only the row(s) a hook actually assembles - (e.g. the normal row in :meth:`ScalarPhysicallyMappedElement.facet_dof_rows`, - discarding the tangential ones) and, for extended/constrained elements - (Bell, Guzman-Neilan), by the final column truncation to - ``self.space_dimension()``. -* :math:`V^c` -- the pure chain-rule/frame content: :class:`FacetFrame` (or - :class:`PiolaFacetFrame`) built from ``J``, giving the diagonal - "own-node" coefficient (``c`` below) and, in the Piola case, the mixing - matrix ``Y``. -* :math:`D` -- the numeric elimination of a completion functional through - *already-assembled* rows of ``V`` (the ``processed`` set), via - :meth:`~finat.functional.PhysicallyMappedFunctional.evaluate`'s - generalized-Vandermonde row (scalar case) or the ``Binv``/``Lmap`` numeric - contractions (Piola case). - -Other recurring symbols, and where the numeric/symbolic boundary falls: - -* ``J`` -- the cell Jacobian, with :math:`x = J\hat x + b` (reference to - physical; see :meth:`~finat.physically_mapped.PhysicalGeometry.jacobian_at`). - This is the *inverse transpose* of the Jacobian in Kirby (2017)'s - convention, whose map :math:`F` goes physical to reference -- a factor - such as a vertex-gradient block of :math:`M` that this module computes as - plain ``J`` is the paper's :math:`J_{\mathrm{paper}}^{-T}`. ``J`` is a - GEM node (indexable, has ``.shape``); :func:`_materialize_jacobian` - produces the numpy object array of GEM scalars that - :func:`~finat.physically_mapped.determinant`/:func:`~finat.physically_mapped.adjugate` - require. -* ``K`` -- the cofactor matrix :math:`\operatorname{adj}(J)^T`, the - contravariant Piola map's action on a (scaled) normal or on a divergence - weight; unrelated to a stiffness matrix despite the common letter. -* ``normal`` -- reference facet normal, but *not* the same scaling in the - two frame classes: :class:`FacetFrame` uses the unit-ish - ``compute_normal`` (its physical image needs rescaling by - ``normal_scale``), while :class:`PiolaFacetFrame` uses the already-scaled - ``compute_scaled_normal`` (its physical image, ``K @ normal``, is used - directly). -* ``direction``, ``weights``, ``order``, ``rank``, ``divergence`` -- the - numeric-or-symbolic fields of a :class:`~finat.functional.PhysicallyMappedFunctional`; - ``direction``/``weights`` are plain numbers as read off the *reference* - FIAT element, and become GEM expressions only after - :meth:`~finat.functional.PhysicallyMappedFunctional.pullback` is applied - (there is no separate type for the two life stages -- track it from - context, as the hooks in this module do). -* ``a``, ``beta`` -- normal/tangential coefficients of a *numeric* direction - in the reference frame (:meth:`FacetFrame.reference_coefficients`). -* ``x`` -- coefficients of a (possibly symbolic) pulled-back direction in - the *physical* frame (:meth:`FacetFrame.decompose`). -* ``c``, ``r`` -- the diagonal own-node coefficient and the tangential - completion residual in :meth:`ScalarPhysicallyMappedElement.facet_dof_rows`. -* ``Dinv`` -- inverse of the numeric direction basis spanning a point-jet or - Cartesian-component group (``point_dof_rows``, ``_piola_point_rows``). -* ``B``, ``Binv``, ``L``, ``Lmap`` -- the tangential-profile Gram system and - the numeric quadrature-point contractions in - :meth:`PiolaPhysicallyMappedElement.facet_dof_rows`; see the "sparsity - gotcha" note there for why the quadrature-point axis must be contracted - numerically before multiplying by any symbolic frame-mixing scalar. + Mardal-Tai-Winther, Johnson-Mercier, Arnold-Winther, Guzman-Neilan. + The roles of the normal and tangential directions are mirrored: the + scaled facet normal is the cofactor image of the reference one, so + pure normal-component moments are invariant, while scaled tangents + map by the Jacobian. Vertex values of tensor-valued fields are + rescaled by :math:`h^{-2}` following the hand-coded Arnold-Winther + and Hu-Zhang conventions. """ -from abc import abstractmethod -from functools import reduce -from operator import add - -import numpy - from FIAT.finite_element import FiniteElement -from gem import Literal, ListTensor, Node, Zero -from finat.functional import PhysicallyMappedFunctional -from finat.physically_mapped import PhysicallyMappedElement, adjugate, determinant, identity - - -def _materialize_jacobian(J: Node) -> numpy.ndarray: - """Materialize a GEM Jacobian node as a numpy object array of GEM scalars. - - :arg J: GEM expression for the cell Jacobian, shape ``(sd, sd)``. - :returns: A ``(sd, sd)`` numpy object array with the same entries as - ``J``, usable with the numeric-or-symbolic linear algebra of - :mod:`finat.physically_mapped` (:func:`~finat.physically_mapped.determinant`, - :func:`~finat.physically_mapped.adjugate`), which index a GEM node's - entries directly rather than through numpy fancy indexing. - """ - sd = J.shape[0] - return numpy.array([[J[i, k] for k in range(sd)] for i in range(sd)], dtype=object) - - -def generalized_cross(tangents) -> numpy.ndarray: - r"""Generalized cross product of d-1 vectors in d dimensions. - - :arg tangents: A (d-1, d) array of vectors, with numeric or GEM entries. - :returns: The vector :math:`C` such that :math:`C \cdot w = - \det([t_1; \dots; t_{d-1}; w])` for all :math:`w`; it is - orthogonal to every :math:`t_k`. - """ - A = numpy.asarray(tangents) - d = A.shape[1] - cols = numpy.ones(d, dtype=bool) - C = [] - for i in range(d): - cols[i] = False - C.append((-1) ** (d - 1 + i) * determinant(A[:, cols])) - cols[i] = True - return numpy.asarray(C) - - -class FacetFrame: - r"""Normal/tangential frame of a facet and its push-forward. - - The reference frame consists of the FIAT facet normal - :math:`\hat{n}` and the scaled facet tangents :math:`\hat{t}_k`; - the physical frame consists of the physical facet normal and the - mapped tangents :math:`J\hat{t}_k`. Because FIAT normals are - computed from the tangents by the same formula on the reference and - physical cells, the physical normal is :math:`\kappa\, C / \|C\|` - with :math:`C` the generalized cross product of the mapped tangents - and :math:`\kappa` a cell-independent constant recovered from the - reference data. - - :arg fiat_element: The FIAT element, providing the reference cell. - :arg entity: The facet number. - :arg J: GEM expression for the cell Jacobian. - """ - - def __init__(self, fiat_element: FiniteElement, entity: int, J: Node): - ref_el = fiat_element.get_reference_element() - sd = ref_el.get_spatial_dimension() - self.tangents = ref_el.compute_tangents(sd - 1, entity) - self.normal = ref_el.compute_normal(entity) - - Chat = generalized_cross(self.tangents) - kappa = self.normal @ Chat / numpy.linalg.norm(Chat) - - self.mapped_tangents = [J @ Literal(t) for t in self.tangents] - C = generalized_cross([[Jt[i] for i in range(sd)] - for Jt in self.mapped_tangents]) - A = numpy.empty((sd, sd), dtype=object) - A[:, 0] = C - for k, Jt in enumerate(self.mapped_tangents): - A[:, k + 1] = [Jt[i] for i in range(sd)] - self._adjA = adjugate(A) - self._detA = determinant(A) - - normC = (C @ C) ** 0.5 - self.normal_scale = normC / kappa - vol = ref_el.volume_of_subcomplex(sd - 1, entity) - self.measure = normC * (vol / numpy.linalg.norm(Chat)) - - def reference_coefficients(self, direction: numpy.ndarray) -> numpy.ndarray: - r"""Expand a numeric direction in the reference frame. - - :arg direction: A numeric direction vector. - :returns: Coefficients ``(a, b_1, ..., b_{d-1})`` such that the - direction equals :math:`a\hat{n} + \sum_k b_k \hat{t}_k`. - """ - A = numpy.column_stack([self.normal, *self.tangents]) - return numpy.linalg.solve(A, direction) - - def decompose(self, direction: Node) -> list: - r"""Expand a GEM direction in the un-normalized physical frame. - - :arg direction: A GEM direction vector. - :returns: GEM coefficients ``(x_0, x_1, ..., x_{d-1})`` such that - the direction equals :math:`x_0 C + \sum_k x_k J\hat{t}_k`. - """ - sd = self._adjA.shape[0] - return [reduce(add, (self._adjA[m, i] * direction[i] - for i in range(sd))) / self._detA - for m in range(sd)] - - -class PiolaFacetFrame: - r"""Normal/tangential frame of a facet for the (double) contravariant - Piola pullback, and its expansion under push-forward. - - The roles of the normal and tangential directions are mirrored with - respect to :class:`FacetFrame`: the reference frame's scaled normal - :math:`\hat{n}` maps by the cofactor matrix :math:`K = - \operatorname{adj}(J)^T`, while the physical tangential directions - (mapped tangents in 2D, cross products of the physical normal with - mapped in-plane vectors in 3D) push forward with a *scalar* - tangential coefficient. ``Y`` is the matrix expanding, in reference - frame coordinates, the pullback of a physical node's frame profile; - it has the closed form - - .. math:: - Y = \begin{pmatrix} 1 & (K\hat{t}_k \cdot K\hat{n})/|K\hat{n}|^2 \\ - 0 & \det J\, |\hat{n}|^2 / |K\hat{n}|^2\, I - \end{pmatrix}, - - valid in 2D and 3D alike: every entry is a Gram contraction of the - :math:`K`-mapped reference frame (:math:`K` is polynomial in - :math:`J`), divided by the single quadratic :math:`|K\hat{n}|^2`. - The top row carries the normal-direction completion residuals; the - diagonal tangential block is the reciprocal of the scalar - push-forward coefficient of the tangential directions. - :arg fiat_element: The FIAT element, providing the reference cell. - :arg entity: The facet number. - :arg J: GEM expression for the cell Jacobian. - """ +from finat.physically_mapped import PhysicallyMappedElement - def __init__(self, fiat_element: FiniteElement, entity: int, J: Node): - ref_el = fiat_element.get_reference_element() - sd = ref_el.get_spatial_dimension() - self.tangents = ref_el.compute_tangents(sd - 1, entity) - self.normal = ref_el.compute_scaled_normal(entity) - Ghat = numpy.column_stack([self.normal, *self.tangents]) - self.Ghatinv = numpy.linalg.inv(Ghat) +class ScalarPhysicallyMappedElement(PhysicallyMappedElement): + """Mixin for scalar elements with an affine (identity) pullback.""" - Jnp = _materialize_jacobian(J) - Knp = adjugate(Jnp).T - detJ = determinant(Jnp) + def _check_mapping(self, fiat_element: FiniteElement) -> None: + """Reject FIAT elements that are not affinely mapped. - Kn = Knp @ self.normal - qKn = Kn @ Kn - s_inv = detJ * (self.normal @ self.normal) / qKn - - Y = numpy.full((sd, sd), Zero(), dtype=object) - Y[0, 0] = Literal(1.0) - for k, that in enumerate(self.tangents): - Y[0, k + 1] = ((Knp @ that) @ Kn) / qKn - Y[k + 1, k + 1] = s_inv - self.Y = Y - - -def _contract_slots(W: numpy.ndarray, A: numpy.ndarray) -> numpy.ndarray: - r"""Contract every tensor slot of W with the matrix A. - - :arg W: A tensor with ``W.ndim`` slots, numeric or GEM entries. - :arg A: The matrix to contract each slot with, numeric or GEM entries. - :returns: The tensor with entries :math:`\sum A_{i_1 j_1} \cdots - A_{i_r j_r} W_{j_1 \dots j_r}`, with the slot order preserved. - """ - for _ in range(W.ndim): - W = numpy.tensordot(W, A, axes=(0, 1)) - return W - - -def _weight_ratio(wi: numpy.ndarray, wj: numpy.ndarray, tol: float) -> float: - """Return the scalar s with wi == s * wj, if it exists.""" - s = wi @ wj / (wj @ wj) - if not numpy.allclose(wi, s * wj, atol=tol * numpy.linalg.norm(wi)): - raise NotImplementedError("Weights are not parallel.") - return s - - -class ZanyPhysicallyMappedElement(PhysicallyMappedElement): - r"""Mixin implementing the entity-by-entity assembly loop shared by - :class:`ScalarPhysicallyMappedElement` and - :class:`PiolaPhysicallyMappedElement`. - - Following the factorization :math:`V = E V^c D` of Kirby (2017) and - Brubeck & Kirby (2025), the matrix :math:`V` relating the reference - nodes to the push-forwards of the physical nodes is assembled one - topological entity at a time, in increasing dimension, so that the - completion of a node on an entity can always be resolved against - the already-assembled rows of lower-dimensional entities. - - On each entity, nodes that are already push-forward invariant - (:meth:`invariant_dofs`) contribute an identity row for free, - since :math:`V` starts out as the identity. The rest are assembled - by :meth:`facet_dof_rows` (on a codimension-1 entity) or - :meth:`point_dof_rows` (elsewhere); these hooks, together with - :meth:`_check_mapping`, encode the mapping-specific (affine or - Piola) part of the theory, and :meth:`basis_transformation` itself - contains no knowledge of which mapping is in play. - """ - - #: Numerical tolerance used throughout automatic basis transformation - #: to detect vanishing coefficients. - tol = 1e-12 - - def basis_transformation(self, coordinate_mapping) -> ListTensor: - fiat_element = self._element - self._check_mapping(fiat_element) - - ref_el = fiat_element.get_reference_element() - sd = ref_el.get_spatial_dimension() - bary, = ref_el.make_points(sd, 0, sd + 1) - J = coordinate_mapping.jacobian_at(bary) - - nodes = fiat_element.dual_basis() - mappings = fiat_element.mapping() - ndof = self.space_dimension() - V = identity(fiat_element.space_dimension()) - - processed = set() - entity_ids = fiat_element.entity_dofs() - for dim in sorted(entity_ids): - for entity in sorted(entity_ids[dim]): - group = {} - for i in entity_ids[dim][entity]: - try: - group[i] = PhysicallyMappedFunctional.from_fiat( - nodes[i], mapping=mappings[i]) - except NotImplementedError: - if i < ndof: - raise - # Constraint functionals of an extended element are - # never exposed as physical dofs: define their - # physical counterparts as the pullbacks of the - # reference ones (Kirby 2017, section 5), keeping the - # identity row; the corresponding columns are - # truncated below. - processed.add(i) - invariant = self.invariant_dofs(group, dim, sd) - processed.update(invariant) - group = {i: ell for i, ell in group.items() if i not in invariant} - if not group: - continue - if dim == sd - 1: - self.facet_dof_rows(V, group, fiat_element, entity, J, processed, tol=self.tol) - else: - self.point_dof_rows(V, group, fiat_element, entity, J, processed, tol=self.tol) - - self._rescale_dofs(V, fiat_element, coordinate_mapping) - return ListTensor(V[:, :ndof].T) - - def _rescale_dofs(self, V: numpy.ndarray, fiat_element: FiniteElement, - coordinate_mapping) -> None: - r"""Rescale the physical degrees of freedom by powers of the cell size. - - Each column of ``V`` is multiplied by :meth:`dof_scale` evaluated - with the cell size averaged over the vertices of the dof's entity. - This is the FInAT convention keeping the mass matrix - well-conditioned; it is consistent across cells because the - scaling only depends on shared entities. - - :arg V: Object array being assembled; columns are rescaled in place. :arg fiat_element: The FIAT element defined on the reference cell. - :arg coordinate_mapping: Object providing the physical geometry as - GEM expressions. - """ - # cell_size may be a GEM expression or a numpy array of numbers - h = coordinate_mapping.cell_size() - top = fiat_element.get_reference_element().get_topology() - nodes = fiat_element.dual_basis() - entity_ids = fiat_element.entity_dofs() - for dim in entity_ids: - for entity in entity_ids[dim]: - verts = top[dim][entity] - havg = reduce(add, (h[v] for v in verts)) / len(verts) - for i in entity_ids[dim][entity]: - scale = self.dof_scale(nodes[i], dim, havg) - if scale is not None: - V[:, i] = V[:, i] * scale - - def dof_scale(self, node, dim: int, havg): - r"""Return the conditioning rescaling factor of one physical dof. - - The default convention redefines each physical node of derivative - order :math:`m > 0` with a factor :math:`h^{-m}`; elements whose - hand-coded transformations established a different convention - (e.g. the :math:`h^{-2}` vertex values of Hu-Zhang) override this - method. - - :arg node: The FIAT functional of the dof. - :arg dim: Topological dimension of the entity the dof sits on. - :arg havg: GEM scalar for the cell size averaged over the vertices - of the dof's entity. - :returns: The GEM scaling factor, or ``None`` for no rescaling. + :raises NotImplementedError: If the pullback is not affine. """ - order = node.max_deriv_order - return havg**(-order) if order > 0 else None - - def _check_mapping(self, fiat_element): - """Verify that this class knows how to transform this element's pullback. - - :arg fiat_element: The FIAT element defined on the reference cell. - :raises NotImplementedError: If the pullback is not supported. - """ - pass - - @abstractmethod - def invariant_dofs(self, group, dim, sd): - """Select the nodes of an entity that are already push-forward invariant. - - :arg group: Dict mapping node index to :class:`PhysicallyMappedFunctional` - for the reference nodes associated with one entity. - :arg dim: Topological dimension of the entity. - :arg sd: Spatial dimension of the cell. - :returns: The subset of ``group`` keys whose row of :math:`V` is - the identity row. - """ - pass - - @abstractmethod - def facet_dof_rows(self, V, group, fiat_element, entity, J, processed, tol): - """Assemble the rows of V for the non-invariant nodes on a facet. - - :arg V: Object array being assembled; rows are set in place. - :arg group: Dict mapping node index to :class:`PhysicallyMappedFunctional` - for the non-invariant reference nodes on this facet. - :arg fiat_element: The FIAT element defined on the reference cell. - :arg entity: The facet number. - :arg J: GEM expression for the cell Jacobian. - :arg processed: Indices of the already assembled rows of ``V``; - updated in place. - :arg tol: Tolerance for detecting zeros in the numeric coefficients. - """ - pass - - @abstractmethod - def point_dof_rows(self, V, group, fiat_element, entity, J, processed, tol): - """Assemble the rows of V for the non-invariant nodes away from a facet. - - :arg V: Object array being assembled; rows are set in place. - :arg group: Dict mapping node index to :class:`PhysicallyMappedFunctional` - for the non-invariant reference nodes on this entity. - :arg fiat_element: The FIAT element defined on the reference cell. - :arg J: GEM expression for the cell Jacobian. - :arg processed: Indices of the already assembled rows of ``V``; - updated in place. - :arg tol: Tolerance for detecting zeros in the numeric coefficients. - """ - pass - - -class ScalarPhysicallyMappedElement(ZanyPhysicallyMappedElement): - r"""Mixin deriving the basis transformation for a scalar element - with an affine (identity) pullback. - - Push-forward invariance and completion follow directly from the - chain rule :math:`\nabla(\hat\psi\circ F) = J^T\hat\nabla\hat\psi - \circ F`: point values pull back unchanged; derivative nodes on a - facet are resolved in the normal/tangential frame of - :class:`FacetFrame`; derivative nodes elsewhere (vertex jets) have - no geometric frame to expand in and instead act as their own - completion group. - """ - - #: If False, physical facet moments are plain integrals rather than - #: the measure-intrinsic integral averages of the reference nodes. - avg = True - - def _check_mapping(self, fiat_element): mappings = set(fiat_element.mapping()) if mappings != {"affine"}: raise NotImplementedError( f"{type(self).__name__} expects an affine pullback, not {mappings}.") - def invariant_dofs(self, group, dim, sd): - # Point values pull back exactly; only derivative nodes need work. - return {i for i, ell in group.items() if ell.order == 0} - - def facet_dof_rows(self, V: numpy.ndarray, group: dict, fiat_element: FiniteElement, - entity: int, J: Node, processed: set, tol: float) -> None: - r"""Assemble the rows of V for derivative nodes on a facet. - - Physical facet nodes take their normal component along the physical - facet normal and their tangential components along the mapped - reference tangents. The pulled-back reference node is expanded in - this frame, and the tangential remainders, being derivatives along - mapped reference tangents, coincide with reference functionals that - are eliminated numerically through already assembled rows of V. - - :arg V: Object array being assembled. - :arg group: Mapping from node index to symbolic PhysicallyMappedFunctional - for the derivative nodes on this facet. - :arg fiat_element: The FIAT element. - :arg entity: The facet number. - :arg J: GEM expression for the cell Jacobian. - :arg processed: Indices of the already assembled rows; updated in place. - :arg tol: Tolerance for detecting zeros in the numeric coefficients. - :arg avg: If False, physical facet moments are plain integrals rather - than integral averages, and their columns are rescaled by the - physical facet measure. - """ - frame = FacetFrame(fiat_element, entity, J) - for i, ell in group.items(): - # Split the direction into normal and tangential parts - a, *beta = frame.reference_coefficients(ell.direction) - if abs(a) < tol: - # Mapped tangential derivatives are invariant - processed.add(i) - continue - - # Expand the pulled-back node in the physical frame - x = frame.decompose(ell.pullback(J).direction) - c = x[0] * frame.normal_scale / a - if not self.avg and len(ell.points) > 1: - # the physical moment is a plain integral, not an average - c = c / frame.measure - - V[i, i] = c - for k, that in enumerate(frame.tangents): - r = x[k + 1] - c * beta[k] - coefficients = ell.with_direction(that).evaluate(fiat_element) - coefficients[abs(coefficients) < tol] = 0 - for j in numpy.flatnonzero(coefficients): - if j not in processed: - raise NotImplementedError( - f"Completion of node {i} couples to node {j}, " - "which has not been transformed yet.") - V[i] += V[j] * (r * coefficients[j]) - processed.add(i) - - def point_dof_rows(self, V: numpy.ndarray, group: dict, fiat_element: FiniteElement, - entity: int, J: Node, processed: set, tol: float) -> None: - r"""Assemble the rows of V for derivative nodes away from facets. - - Away from facets there is no geometric frame, and physical nodes - keep the reference (Cartesian) directions, so the group must span - all directions and acts as its own completion: this is the - affine-interpolation equivalent case, and each pulled-back node is - expanded within the group. - - :arg V: Object array being assembled. - :arg group: Mapping from node index to symbolic PhysicallyMappedFunctional - for the derivative nodes on this entity. - :arg J: GEM expression for the cell Jacobian. - :arg processed: Indices of the already assembled rows; updated in place. - :arg tol: Tolerance for detecting zeros in the numeric coefficients. - """ - suborders = {} - for i, ell in group.items(): - suborders.setdefault(ell.order, {})[i] = ell - - for sub in suborders.values(): - directions = numpy.array([ell.direction for ell in sub.values()]) - if len(set(ell.points for ell in sub.values())) > 1: - raise NotImplementedError("Group nodes at different points.") - if directions.shape[0] != directions.shape[1]: - raise NotImplementedError( - "Directions do not span the derivative jet.") - - # coefficients of the direction basis expansion of each multi-index - Dinv = numpy.linalg.inv(directions.T) - for i, ell in sub.items(): - Jd = ell.pullback(J).direction - for col, (j, ellj) in enumerate(sub.items()): - s = _weight_ratio(ell.weights, ellj.weights, tol) - x = s * Dinv[col] - nz = numpy.flatnonzero(abs(x) > tol) - V[i, j] = reduce(add, (Jd[m] * x[m] for m in nz)) if len(nz) else Zero() - processed.add(i) - -class PiolaPhysicallyMappedElement(ZanyPhysicallyMappedElement): - r"""Mixin deriving the basis transformation for a vector- or - tensor-valued element under the (double) contravariant Piola - pullback. +class PiolaPhysicallyMappedElement(PhysicallyMappedElement): + """Mixin for vector- or tensor-valued elements under the (double) + contravariant Piola pullback.""" - The roles of the normal and tangential directions are mirrored with - respect to the scalar case: interior value moments are Piola - invariant by construction; value moments on a facet are resolved in - the frame of the scaled facet normal (the cofactor image of the - reference one) and the mapped reference tangents; point evaluations - elsewhere have no geometric frame to expand in and instead act as - their own completion group. - """ + def _check_mapping(self, fiat_element: FiniteElement) -> None: + """Reject FIAT elements that are not (double) contravariant Piola mapped. - @staticmethod - def _check_piola_group(group: dict) -> None: - """Validate that a group of non-invariant Piola-mapped nodes are all - value moments with a nonzero value rank. - - :arg group: Mapping from node index to symbolic PhysicallyMappedFunctional. - :raises NotImplementedError: If a node is a scalar weight or carries - a derivative, which the theory does not yet cover. - """ - if any(ell.rank == 0 or ell.order > 0 for ell in group.values()): - raise NotImplementedError("Cannot yet Piola-transform this node group.") - - @staticmethod - def _divergence_rows(V: numpy.ndarray, group: dict, J: Node, processed: set) -> dict: - r"""Assemble the rows of V for divergence nodes and strip them from the group. - - The (double) contravariant Piola pullback commutes with the - divergence up to the Jacobian determinant, independently of the - entity the node sits on, so each divergence node is its own, - trivially invariant group. - - :arg V: Object array being assembled; rows are set in place. - :arg group: Mapping from node index to symbolic PhysicallyMappedFunctional - for the nodes on this entity. - :arg J: GEM expression for the cell Jacobian. - :arg processed: Indices of the already assembled rows; updated in place. - :returns: The remaining, non-divergence nodes of ``group``. - """ - divs = {i: ell for i, ell in group.items() if ell.divergence} - if divs: - detJ = determinant(_materialize_jacobian(J)) - for i, ell in divs.items(): - V[i, i] = detJ * ell.weights[0] - processed.add(i) - return {i: ell for i, ell in group.items() if i not in divs} - - @staticmethod - def _piola_point_rows(V: numpy.ndarray, group: dict, fiat_element: FiniteElement, - J: Node, processed: set, tol: float) -> None: - r"""Assemble the rows of V for Cartesian point values of Piola-mapped fields. - - Physical point evaluations keep the reference (Cartesian) components, - which pull back through the cofactor matrix :math:`K = - \operatorname{adj}(J)^T` of the contravariant Piola map, contracted - once per value slot, so the group of components sharing a point acts - as its own completion. - - A weight tensor is only defined as a functional modulo the - annihilator of the value space attainable at the point: e.g. the - vertex weights of Hu-Zhang select one component of a *symmetric* - stress, so the raw upper-triangular weights and their symmetrizations - are the same functionals. The value space is recovered numerically - from the tabulated basis, both sides of the expansion are projected - onto it, and the group acts as its own completion if its projected - span is invariant under the (projected) slot map for every Jacobian, - which is checked with a generic matrix. - - :arg V: Object array being assembled. - :arg group: Mapping from node index to symbolic PhysicallyMappedFunctional - for the value nodes on this entity. - :arg fiat_element: The FIAT element, providing the tabulated value - space at each point. - :arg J: GEM expression for the cell Jacobian. - :arg processed: Indices of the already assembled rows; updated in place. - :arg tol: Tolerance for detecting zeros in the numeric coefficients. + :arg fiat_element: The FIAT element defined on the reference cell. + :raises NotImplementedError: If the pullback is not supported. """ - Jnp = _materialize_jacobian(J) - K = adjugate(Jnp).T - sd = K.shape[0] - - subgroups = {} - for i, ell in group.items(): - if len(ell.points) != 1: - raise NotImplementedError( - "Only single-point evaluations are handled.") - subgroups.setdefault((ell.points, ell.rank), {})[i] = ell - - for (points, rank), sub in subgroups.items(): - directions = numpy.array([ell.weights[0] for ell in sub.values()]) - # Projector onto the value space attainable at the point - tab = fiat_element.tabulate(0, points)[(0,) * sd] - u, s, vt = numpy.linalg.svd(tab.reshape(tab.shape[0], -1), - full_matrices=False) - vt = vt[s > tol * s[0]] - P = vt.T @ vt - # Coefficient extractor: project a raw mapped weight, then expand - # it in the projected group weights - E = numpy.linalg.pinv((directions @ P).T) @ P - # The group acts as its own completion only if its projected span - # is invariant under the slot map for every Jacobian; check with - # a generic matrix. - A = numpy.cos(numpy.multiply.outer(numpy.arange(1., sd + 1), - numpy.arange(2., 2 * sd + 2, 2))) - mapped = numpy.stack([ - _contract_slots(w.reshape((sd,) * rank), A).ravel() - for w in directions]) - residual = (mapped - (mapped @ E.T) @ directions) @ P - if not numpy.allclose(residual, 0, atol=tol * numpy.abs(mapped).max()): - raise NotImplementedError( - "Node group does not span a slot-map invariant subspace.") - E[abs(E) < tol] = 0 - for i, ell in sub.items(): - Kd = _contract_slots(ell.weights[0].reshape((sd,) * rank), K).ravel() - for col, j in enumerate(sub): - x = E[col] - nz = numpy.flatnonzero(x) - V[i, j] = reduce(add, (Kd[m] * x[m] for m in nz)) if len(nz) else Zero() - processed.add(i) - - def _check_mapping(self, fiat_element): mappings = set(fiat_element.mapping()) if mappings not in ({"contravariant piola"}, {"double contravariant piola"}): raise NotImplementedError( @@ -730,187 +81,3 @@ def dof_scale(self, node, dim: int, havg): if len(max(comps)) == 2: return havg**(-2) return super().dof_scale(node, dim, havg) - - def invariant_dofs(self, group, dim, sd): - # Interior *moments* are Piola invariant by construction: their - # physical test functions are themselves Piola-mapped. Single-point - # nodes are Cartesian point data even in the interior (e.g. the - # barycenter values of Guzman-Neilan second kind) and transform - # through the cofactor matrix instead. - return {i for i, ell in group.items() - if ell.order == 0 and dim == sd and len(ell.points) > 1} - - def facet_dof_rows(self, V: numpy.ndarray, group: dict, - fiat_element: FiniteElement, entity: int, J: Node, - processed: set, tol: float) -> None: - r"""Assemble the rows of V for facet moments of Piola-mapped values. - - This mirrors :func:`_scalar_facet_rows` with the roles of the normal - and tangential directions exchanged: under the contravariant Piola - map the scaled facet normal is the image of the reference one under - the cofactor matrix :math:`K = \operatorname{adj}(J)^T`, so pure - normal moments are invariant, while the scaled tangents map by - :math:`J`. Distinct nodes on a facet can share the same tangential - directions (e.g. two RT-type facet dofs in 3D) and are only told - apart by how their weight varies from point to point, so a node is - identified by its full per-point profile of frame coordinates, not - by direction alone; the pulled-back reference node mixes the - coordinates through the frame expansion of :math:`K\hat{t}_k`, the - tangential profiles are matched within the group, and the residual - normal profile is eliminated numerically through already assembled - rows of V. - - :arg V: Object array being assembled. - :arg group: Mapping from node index to symbolic PhysicallyMappedFunctional - for the value moments on this facet. - :arg fiat_element: The FIAT element. - :arg entity: The facet number. - :arg J: GEM expression for the cell Jacobian. - :arg processed: Indices of the already assembled rows; updated in place. - :arg tol: Tolerance for detecting zeros in the numeric coefficients. - """ - group = self._divergence_rows(V, group, J, processed) - if not group: - return - PiolaPhysicallyMappedElement._check_piola_group(group) - # Single-point rank-1 nodes are Cartesian point data, not facet - # moments (whose weights are genuine, usually multi-point quadrature - # profiles aligned with the facet frame), even when they sit on a - # codimension-1 entity: e.g. the edge midpoint values of - # AlfeldSorokina. Single-point rank-2 nodes remain frame dofs - # (e.g. the edge dofs of the Hu-Zhang point variant). - point_data = {i: ell for i, ell in group.items() - if len(ell.points) == 1 and ell.rank == 1} - if point_data: - self._piola_point_rows(V, point_data, fiat_element, J, processed, tol) - group = {i: ell for i, ell in group.items() if i not in point_data} - if not group: - return - sd = J.shape[0] - frame = PiolaFacetFrame(fiat_element, entity, J) - nhat = frame.normal - Y = frame.Y - - # Reference frame coordinate profiles, shared with the physical nodes: - # each node's own quadrature weights, decomposed into (normal, - # tangential) frame coordinates at each of its points. This is what - # distinguishes nodes with the same tangential directions from one - # another (see the point-dependence note in the docstring above). - coords = {} - for i, ell in group.items(): - C = ell.weights.reshape(-1, *(sd,) * ell.rank) - for _ in range(ell.rank): - C = numpy.tensordot(C, frame.Ghatinv, axes=(1, 1)) - coords[i] = C.reshape(len(ell.points), -1) - # Pure normal moments are Piola invariant - if numpy.allclose(coords[i][:, 1:], 0, atol=tol): - processed.add(i) - - group = {i: ell for i, ell in group.items() if i not in processed} - if not group: - return - # Nodes on different point sets or of different value rank (e.g. the - # mixed-degree edge moments of the Hu-Zhang integral variant) match - # their tangential profiles within their own subgroup; a genuine - # cross-subgroup coupling would surface below as a completion - # against an unprocessed node. - subgroups = {} - for i, ell in group.items(): - subgroups.setdefault((ell.points, ell.rank), {})[i] = ell - - for (points, rank), sub in subgroups.items(): - # Numeric matching of the tangential coordinate profiles in the - # subgroup. B has full row rank by unisolvence, so the Gram - # matrix B @ B.T is square and invertible; a rank deficiency (a - # genuine bug) surfaces as a LinAlgError here rather than a - # silent least-squares fit. - B = numpy.array([coords[j][:, 1:].ravel() for j in sub]) - Binv = numpy.linalg.inv(B @ B.T) @ B - Binv[abs(Binv) < tol] = 0 - - # Numeric elimination of the residual normal profile against - # every basis function of the element (not just this facet's own - # normal dofs, since a completing dof may live on a different - # point set, e.g. Guzman-Neilan's mixed-order facet dofs). The - # quadrature-point axis is contracted purely numerically here, - # one reference-frame multi-index at a time, so that whether a - # coupling is present is decided from a plain numeric array - # (exact zero where a profile has no support at these points) - # rather than by inspecting the type of a symbolic GEM - # expression -- a sum of symbolic terms that is identically zero - # for all J need not reduce to a literal gem.Zero() node, so - # testing that structurally would either miss real cancellations - # or (as here) spuriously keep terms whose numeric coefficient - # actually vanishes. - ndir = numpy.ones(()) - for _ in range(rank): - ndir = numpy.multiply.outer(ndir, nhat) - T = fiat_element.tabulate(0, points)[(0,) * sd] - L = numpy.einsum("jcq,c->jq", T.reshape(T.shape[0], -1, len(points)), - ndir.ravel()) - L[abs(L) < tol] = 0 - # Lmap[i][m, r] = sum_q L[m, q] * coords[i][q, r]: numeric - # coupling of every basis function to each frame coordinate of - # node i's own profile, contracted over the shared quadrature - # points. - Lmap = {i: L @ coords[i] for i in sub} - for i in Lmap: - Lmap[i][abs(Lmap[i]) < tol] = 0 - - for i, ell in sub.items(): - # Pull back the coordinate profile, contracting each slot with Y - P = coords[i].reshape(-1, *(sd,) * rank) - for _ in range(rank): - P = numpy.tensordot(P, Y, axes=(1, 1)) - P = P.reshape(len(points), -1) - - c = Binv @ P[:, 1:].ravel() - V[i, list(sub)] = c - - # Couple the residual normal profile to every basis function, - # one reference-frame multi-index (or subgroup member) at a - # time: each numeric column of Lmap decides its own sparsity, - # and the small symbolic frame-mixing coefficient only scales - # terms that already survived the numeric test. - terms = [] - for index in numpy.ndindex(*(sd,) * rank): - flat = numpy.ravel_multi_index(index, (sd,) * rank) - coef = Literal(1.0) - for r in index: - coef = coef * Y[0, r] - terms.append((Lmap[i][:, flat], coef)) - for k, j in enumerate(sub): - terms.append((-Lmap[j][:, 0], c[k])) - - for vec, coef in terms: - for m in numpy.flatnonzero(vec): - if m not in processed: - raise NotImplementedError( - f"Completion of node {i} couples to node {m}, " - "which has not been transformed yet.") - V[i] += V[m] * (vec[m] * coef) - processed.add(i) - - def point_dof_rows(self, V: numpy.ndarray, group: dict, - fiat_element: FiniteElement, entity: int, J: Node, - processed: set, tol: float) -> None: - r"""Assemble the rows of V for point values of Piola-mapped fields. - - This mirrors :func:`_scalar_point_rows`: away from facets, physical - point evaluations keep the reference (Cartesian) components, which - pull back through the cofactor matrix :math:`K = \operatorname{adj}(J)^T` - of the contravariant Piola map, so the group of components at each - point acts as its own completion. - - :arg V: Object array being assembled. - :arg group: Mapping from node index to symbolic PhysicallyMappedFunctional - for the value nodes on this entity. - :arg J: GEM expression for the cell Jacobian. - :arg processed: Indices of the already assembled rows; updated in place. - :arg tol: Tolerance for detecting zeros in the numeric coefficients. - """ - group = self._divergence_rows(V, group, J, processed) - if not group: - return - PiolaPhysicallyMappedElement._check_piola_group(group) - self._piola_point_rows(V, group, fiat_element, J, processed, tol) diff --git a/zany_claude.md b/zany_claude.md index a9e2d944a..b79eb3e0d 100644 --- a/zany_claude.md +++ b/zany_claude.md @@ -788,3 +788,60 @@ builds a symbolic physical Gram $G$ and its determinant for `Sinv`. Risk framing unchanged from PLAN.md: abandon any piece that adds more complexity than it removes; the theory above is the review artifact to be signed off *before* production code moves. + +--- + +## Stage 5 SHIPPED — duality engine as the default `basis_transformation` (2026-07-19) + +The mixin-unification plan above (Stage 5c/"Implementation plan" items 1–3) +was **superseded by a course change**: instead of merging the frame classes, +the numeric prototype of `test/finat/test_claude_zany_piola.py` was turned +directly into product code. `PhysicallyMappedElement.basis_transformation` +(`finat/physically_mapped.py`) now assembles $B_{ij} = n_i(\hat\psi_j \circ +F^{-1})$ symbolically in GEM and inverts it sparsely; `finat/zany.py` shrank +from ~900 lines (FacetFrame, PiolaFacetFrame, four hooks, two mixins) to two +thin configuration mixins (`_check_mapping` guards + the Piola `dof_scale` +override). Paper §4 ("Assembly by duality and sparsity-preserving +inversion") documents the theory. + +**Engine design (all in `finat/physically_mapped.py`):** + +1. *Per-slot maps, adjoint on the tabulation.* One effective matrix per + slot: $G = J^{-1}\Phi$ (derivative slots), $G = (J/\det J)^T\Phi$ (value + slots), applied to the reference direction/weights via the generalized + `PhysicallyMappedFunctional.pullback(A)` (now accepts any numeric or GEM + matrix); rows of $B$ are then numeric Vandermonde pairings. Numerator/ + denominator form keeps numerators polynomial: $s_i = (\det J\,|K\hat n|)^m$ + for scalar facet rows, $\det J^{2r}$ for Piola facet rows (using the exact + identity $J^T K\hat\nu^s = \det J\, \hat\nu^s$), $\det J$ for divergence. + +2. *Sparsity and constants inferred from numerics.* $B$ is assembled at 3 + deterministic sample Jacobians (`_sample_jacobians`, cosine-based, one + orientation-reversing). Entries below `pattern_tol = 1e-10` (row-relative) + at every sample are structural `gem.Zero`; entries equal at every sample + are constants (snapped to integers where they round), so invariant rows + never touch symbolic algebra. No closure bookkeeping, no invariance case + analysis, no support-law *assumption* — the pattern is measured. + +3. *SCC block triangularization + sparse back-substitution.* Tarjan on the + coupling digraph emits diagonal blocks in dependency order; + $V_S = (B'_{SS})^{-1}(\mathrm{diag}(s_i) I_S - B'_{Sc}V_c)$ with one + adjugate/determinant pair per block, cached across identical blocks + (vertex jets of different vertices hit the cache). GEM constant folding + through `Zero` keeps everything sparse; gotcha: numpy object arrays must + be the *left* operand of elementwise products, else GEM broadcasts a + vector node (`V[j] * coef`, not `coef * V[j]`). + +**Verification:** full finat suite 430 passed / 8 skipped (includes +`test_zany_mapping` direct physical fits, both orientations, and the +independent prototype cross-check in `test_claude_zany_piola.py`, which +still shares no code path with `basis_transformation`); flake8 + pydocstyle +clean; opaque `gem.Variable` Jacobian path verified bit-identical to the +literal path with max 3–9 nonzeros per row of $M$. + +**Numeric groundwork:** pattern-only elimination validated over the entire +scalar + Piola zoos before the symbolic build (scratch `validate_pattern.py`): +max diagonal block 5×5 (BrambleZlamalC2 order-4 jets), pattern leak ≤ 1e-14 +except BZC2 (~1e-10, its known FIAT-side conditioning). Hand-coded +transformations (HCT, PS, C2 elements, WuXu, Walkington) still override the +engine and are untouched. From 080c8b8b9e987892340a4f73d1849ecce8541449 Mon Sep 17 00:00:00 2001 From: Pablo Brubeck Date: Sun, 19 Jul 2026 18:12:03 +0100 Subject: [PATCH 34/34] use new code --- finat/alfeld_sorokina.py | 40 ++++++++--- finat/argyris.py | 72 +++++++++++++++++--- finat/arnold_qin.py | 11 +-- finat/aw.py | 107 ++++++++++++++++++++++++----- finat/bell.py | 70 +++++++++++++++---- finat/bernardi_raugel.py | 12 +--- finat/christiansen_hu.py | 11 +-- finat/guzman_neilan.py | 34 ++-------- finat/hermite.py | 30 ++++++-- finat/hz.py | 46 ++++++++++--- finat/johnson_mercier.py | 30 +++++--- finat/morley.py | 78 +++++++++++++++++++-- finat/mtw.py | 37 ++++++++-- finat/piola_mapped.py | 143 +++++++++++++++++++++++++++++++++++---- finat/walkington.py | 28 +------- finat/wuxu.py | 11 ++- 16 files changed, 572 insertions(+), 188 deletions(-) diff --git a/finat/alfeld_sorokina.py b/finat/alfeld_sorokina.py index 820faf718..7eb7336f1 100644 --- a/finat/alfeld_sorokina.py +++ b/finat/alfeld_sorokina.py @@ -1,17 +1,41 @@ import FIAT +import numpy +from gem import ListTensor from finat.citations import cite from finat.fiat_elements import FiatElement -from finat.zany import PiolaPhysicallyMappedElement +from finat.physically_mapped import identity, PhysicallyMappedElement +from finat.piola_mapped import piola_inverse -class AlfeldSorokina(PiolaPhysicallyMappedElement, FiatElement): - """The Alfeld-Sorokina C0 quadratic macroelement with C0 divergence. - - This element belongs to a Stokes complex, and is paired with CG1(Alfeld). - The basis transformation is derived automatically from the FIAT dual - basis; see :class:`finat.zany.PiolaPhysicallyMappedElement`. - """ +class AlfeldSorokina(PhysicallyMappedElement, FiatElement): def __init__(self, cell, degree=2): cite("AlfeldSorokina2016") super().__init__(FIAT.AlfeldSorokina(cell, degree)) + + def _basis_transformation(self, coordinate_mapping): + sd = self.cell.get_spatial_dimension() + bary, = self.cell.make_points(sd, 0, sd+1) + J = coordinate_mapping.jacobian_at(bary) + detJ = coordinate_mapping.detJ_at(bary) + + dofs = self.entity_dofs() + V = identity(self.space_dimension()) + + # Undo the Piola transform + nodes = self._element.get_dual_set().nodes + Finv = piola_inverse(self.cell, J, detJ) + for dim in sorted(dofs): + for e in sorted(dofs[dim]): + k = 0 + while k < len(dofs[dim][e]): + cur = dofs[dim][e][k] + if len(nodes[cur].deriv_dict) > 0: + V[cur, cur] = detJ + k += 1 + else: + s = dofs[dim][e][k:k+sd] + V[numpy.ix_(s, s)] = Finv + k += sd + + return ListTensor(V.T) diff --git a/finat/argyris.py b/finat/argyris.py index 1d4d0913e..02f027668 100644 --- a/finat/argyris.py +++ b/finat/argyris.py @@ -4,12 +4,11 @@ import FIAT -from gem import Literal, Zero +from gem import Literal, ListTensor, Zero from finat.citations import cite from finat.fiat_elements import ScalarFiatElement -from finat.physically_mapped import identity -from finat.zany import ScalarPhysicallyMappedElement +from finat.physically_mapped import identity, PhysicallyMappedElement def _jet_transform(J, order): @@ -128,18 +127,71 @@ def _edge_transform(V, vorder, eorder, fiat_cell, coordinate_mapping, avg=False) V[s, s + eorder] = -Bnt -class Argyris(ScalarPhysicallyMappedElement, ScalarFiatElement): - """The Argyris element. - - The basis transformation is derived automatically from the FIAT - dual basis; see :class:`finat.zany.ScalarPhysicallyMappedElement`. - """ +class Argyris(PhysicallyMappedElement, ScalarFiatElement): def __init__(self, cell, degree=5, variant=None, avg=False): cite("Argyris1968") if variant is None: variant = "integral" if variant == "point" and degree != 5: raise NotImplementedError("Degree must be 5 for 'point' variant of Argyris") + fiat_element = FIAT.Argyris(cell, degree, variant=variant) self.variant = variant self.avg = avg - super().__init__(FIAT.Argyris(cell, degree, variant=variant)) + super().__init__(fiat_element) + + def _basis_transformation(self, coordinate_mapping): + sd = self.cell.get_spatial_dimension() + top = self.cell.get_topology() + + V = identity(self.space_dimension()) + + vorder = 2 + voffset = comb(sd + vorder, vorder) + eorder = self.degree - 5 + + _vertex_transform(V, vorder, self.cell, coordinate_mapping) + if self.variant == "integral": + _edge_transform(V, vorder, eorder, self.cell, coordinate_mapping, avg=self.avg) + else: + bary, = self.cell.make_points(sd, 0, sd+1) + J = coordinate_mapping.jacobian_at(bary) + detJ = coordinate_mapping.detJ_at(bary) + pel = coordinate_mapping.physical_edge_lengths() + for e in sorted(top[1]): + s = len(top[0]) * voffset + e * (eorder+1) + v0id, v1id = (v * voffset for v in top[1][e]) + Bnn, Bnt, Jt = _normal_tangential_transform(self.cell, J, detJ, e) + + # edge midpoint normal derivative + V[s, s] = Bnn * pel[e] + + # vertex points + V[s, v1id] = 15/8 * Bnt + V[s, v0id] = -V[s, v1id] + + # vertex derivatives + for i in range(sd): + V[s, v1id+1+i] = -7/16 * Bnt * Jt[i] + V[s, v0id+1+i] = V[s, v1id+1+i] + + # second derivatives + tau = [Jt[0]*Jt[0], 2*Jt[0]*Jt[1], Jt[1]*Jt[1]] + for i in range(len(tau)): + V[s, v1id+3+i] = 1/32 * Bnt * tau[i] + V[s, v0id+3+i] = -V[s, v1id+3+i] + + # Patch up conditioning + h = coordinate_mapping.cell_size() + for v in sorted(top[0]): + s = voffset*v + 1 + V[:, s:s+sd] *= 1 / h[v] + V[:, s+sd:voffset*(v+1)] *= 1 / (h[v]*h[v]) + + if self.variant == "point": + eoffset = 2 * eorder + 1 + for e in sorted(top[1]): + v0, v1 = top[1][e] + s = len(top[0]) * voffset + e * eoffset + V[:, s:s+eorder+1] *= 2 / (h[v0] + h[v1]) + + return ListTensor(V.T) diff --git a/finat/arnold_qin.py b/finat/arnold_qin.py index 7daf559fa..67d9cc378 100644 --- a/finat/arnold_qin.py +++ b/finat/arnold_qin.py @@ -3,7 +3,6 @@ from finat.citations import cite from finat.fiat_elements import FiatElement from finat.piola_mapped import PiolaBubbleElement -from finat.zany import PiolaPhysicallyMappedElement class ArnoldQin(FiatElement): @@ -12,15 +11,7 @@ def __init__(self, cell, degree=2): super().__init__(FIAT.ArnoldQin(cell, degree)) -class ReducedArnoldQin(PiolaPhysicallyMappedElement, PiolaBubbleElement): - """The reduced Arnold-Qin element. - - The basis transformation is derived automatically from the FIAT - dual basis (see :class:`finat.zany.PiolaPhysicallyMappedElement`); - the trailing tangential facet constraints of the extended element - are dropped from the physical element by - :meth:`~finat.piola_mapped.PiolaBubbleElement.space_dimension`. - """ +class ReducedArnoldQin(PiolaBubbleElement): def __init__(self, cell, degree=2): cite("ArnoldQin1992") super().__init__(FIAT.ArnoldQin(cell, degree, reduced=True)) diff --git a/finat/aw.py b/finat/aw.py index 0b23f60e8..df990e587 100644 --- a/finat/aw.py +++ b/finat/aw.py @@ -1,23 +1,75 @@ """Implementation of the Arnold-Winther finite elements.""" import FIAT +import numpy +from gem import ListTensor from finat.citations import cite from finat.fiat_elements import FiatElement -from finat.zany import PiolaPhysicallyMappedElement +from finat.physically_mapped import adjugate, identity, PhysicallyMappedElement +from finat.piola_mapped import normal_tangential_transform -class ArnoldWintherNC(PiolaPhysicallyMappedElement, FiatElement): - """The nonconforming Arnold-Winther element. +def _facet_transform(fiat_cell, facet_moment_degree, coordinate_mapping): + sd = fiat_cell.get_spatial_dimension() + top = fiat_cell.get_topology() + num_facets = len(top[sd-1]) + dimPk_facet = FIAT.expansions.polynomial_dimension( + fiat_cell.construct_subelement(sd-1), facet_moment_degree) + dofs_per_facet = sd * dimPk_facet + ndofs = num_facets * dofs_per_facet + V = identity(ndofs) - The basis transformation is derived automatically from the FIAT - dual basis (see :class:`finat.zany.PiolaPhysicallyMappedElement`); - the trailing constraint functionals of the extended element are - dropped from the physical element by :meth:`space_dimension`. - """ + bary, = fiat_cell.make_points(sd, 0, sd+1) + J = coordinate_mapping.jacobian_at(bary) + detJ = coordinate_mapping.detJ_at(bary) + for f in range(num_facets): + Bnt, Btt = normal_tangential_transform(fiat_cell, J, detJ, f) + for i in range(dimPk_facet): + s = dofs_per_facet*f + i * sd + ndof = s + tdofs = range(s+1, s+sd) + V[tdofs, ndof] = Bnt + V[tdofs, tdofs] = Btt + + return V + + +def _evaluation_transform(fiat_cell, coordinate_mapping): + sd = fiat_cell.get_spatial_dimension() + bary, = fiat_cell.make_points(sd, 0, sd+1) + J = coordinate_mapping.jacobian_at(bary) + K = adjugate([[J[i, j] for j in range(sd)] for i in range(sd)]) + + indices = [(i, j) for i in range(sd) for j in range(i, sd)] + ncomp = len(indices) + W = numpy.zeros((ncomp, ncomp), dtype=object) + for p, (i, j) in enumerate(indices): + for q, (m, n) in enumerate(indices): + W[p, q] = 0.5*(K[i, m] * K[j, n] + K[j, m] * K[i, n]) + W[:, [i != j for i, j in indices]] *= 2 + return W + + +class ArnoldWintherNC(PhysicallyMappedElement, FiatElement): def __init__(self, cell, degree=2): cite("Arnold2003") super().__init__(FIAT.ArnoldWintherNC(cell, degree)) + def _basis_transformation(self, coordinate_mapping): + """Note, the extra 3 dofs which are removed here + correspond to the constraints.""" + numbf = self._element.space_dimension() + ndof = self.space_dimension() + V = identity(numbf, ndof) + + V[:12, :12] = _facet_transform(self.cell, 1, coordinate_mapping) + + # Note: that the edge DOFs are scaled by edge lengths in FIAT implies + # that they are already have the necessary rescaling to improve + # conditioning. + + return ListTensor(V.T) + def entity_dofs(self): return {0: {0: [], 1: [], @@ -29,18 +81,41 @@ def space_dimension(self): return 15 -class ArnoldWinther(PiolaPhysicallyMappedElement, FiatElement): - """The conforming Arnold-Winther element. - - The basis transformation is derived automatically from the FIAT - dual basis (see :class:`finat.zany.PiolaPhysicallyMappedElement`); - the trailing constraint functionals of the extended element are - dropped from the physical element by :meth:`space_dimension`. - """ +class ArnoldWinther(PhysicallyMappedElement, FiatElement): def __init__(self, cell, degree=3): cite("Arnold2002") super().__init__(FIAT.ArnoldWinther(cell, degree)) + def _basis_transformation(self, coordinate_mapping): + # The extra 6 dofs removed here correspond to the constraints + numbf = self._element.space_dimension() + ndof = self.space_dimension() + V = identity(numbf, ndof) + + sd = self.cell.get_spatial_dimension() + W = _evaluation_transform(self.cell, coordinate_mapping) + ncomp = W.shape[0] + + # Put into the right rows and columns. + V[0:3, 0:3] = V[3:6, 3:6] = V[6:9, 6:9] = W + num_verts = sd + 1 + cur = num_verts * ncomp + + Vsub = _facet_transform(self.cell, 1, coordinate_mapping) + fdofs = Vsub.shape[0] + V[cur:cur+fdofs, cur:cur+fdofs] = Vsub + cur += fdofs + + # RESCALING FOR CONDITIONING + h = coordinate_mapping.cell_size() + for e in range(num_verts): + V[:, ncomp*e:ncomp*(e+1)] *= 1/(h[e] * h[e]) + + # Note: that the edge DOFs are scaled by edge lengths in FIAT implies + # that they are already have the necessary rescaling to improve + # conditioning. + return ListTensor(V.T) + def entity_dofs(self): return {0: {0: [0, 1, 2], 1: [3, 4, 5], diff --git a/finat/bell.py b/finat/bell.py index 7cfdc3057..aca226762 100644 --- a/finat/bell.py +++ b/finat/bell.py @@ -1,21 +1,15 @@ import FIAT -from copy import deepcopy +from math import comb +from gem import ListTensor from finat.citations import cite from finat.fiat_elements import ScalarFiatElement -from finat.zany import ScalarPhysicallyMappedElement - +from finat.physically_mapped import identity, PhysicallyMappedElement +from finat.argyris import _vertex_transform, _normal_tangential_transform +from copy import deepcopy -class Bell(ScalarPhysicallyMappedElement, ScalarFiatElement): - """The Bell element. - FIAT provides the extended element on the full quintic space, with - the cubic normal derivative constraints appended as extra edge - nodes; the transformation of the extended element is derived - automatically (see :class:`finat.zany.ScalarPhysicallyMappedElement`), - and the constraint degrees of freedom are dropped from the physical - element by overriding :meth:`space_dimension`. - """ +class Bell(PhysicallyMappedElement, ScalarFiatElement): def __init__(self, cell, degree=5): cite("Bell1969") super().__init__(FIAT.Bell(cell, degree=degree)) @@ -26,8 +20,56 @@ def __init__(self, cell, degree=5): reduced_dofs[sd-1][entity] = [] self._entity_dofs = reduced_dofs - # The extended FIAT element has 21 basis functions, but the Bell - # element only keeps the 18 vertex degrees of freedom. + def _basis_transformation(self, coordinate_mapping): + # Jacobian at barycenter + sd = self.cell.get_spatial_dimension() + top = self.cell.get_topology() + bary, = self.cell.make_points(sd, 0, sd+1) + J = coordinate_mapping.jacobian_at(bary) + detJ = coordinate_mapping.detJ_at(bary) + + numbf = self._element.space_dimension() + ndof = self.space_dimension() + # rectangular to toss out the constraint dofs + V = identity(numbf, ndof) + + vorder = 2 + _vertex_transform(V, vorder, self.cell, coordinate_mapping) + + voffset = comb(sd + vorder, vorder) + for e in sorted(top[1]): + s = len(top[0]) * voffset + e + v0id, v1id = (v * voffset for v in top[1][e]) + Bnn, Bnt, Jt = _normal_tangential_transform(self.cell, J, detJ, e) + + # vertex points + V[s, v1id] = 1/21 * Bnt + V[s, v0id] = -V[s, v1id] + + # vertex derivatives + for i in range(sd): + V[s, v1id+1+i] = -1/42 * Bnt * Jt[i] + V[s, v0id+1+i] = V[s, v1id+1+i] + + # second derivatives + tau = [Jt[0]*Jt[0], 2*Jt[0]*Jt[1], Jt[1]*Jt[1]] + for i in range(len(tau)): + V[s, v1id+3+i] = 1/252 * Bnt * tau[i] + V[s, v0id+3+i] = -V[s, v1id+3+i] + + # Patch up conditioning + h = coordinate_mapping.cell_size() + for v in sorted(top[0]): + s = voffset * v + 1 + V[:, s:s+sd] *= 1/h[v] + V[:, s+sd:voffset*(v+1)] *= 1/(h[v]*h[v]) + + return ListTensor(V.T) + + # This wipes out the edge dofs. FIAT gives a 21 DOF element + # because we need some extra functions to help with transforming + # under the edge constraint. However, we only have an 18 DOF + # element. def entity_dofs(self): return self._entity_dofs diff --git a/finat/bernardi_raugel.py b/finat/bernardi_raugel.py index 2e35c3cbd..4dd461f87 100644 --- a/finat/bernardi_raugel.py +++ b/finat/bernardi_raugel.py @@ -2,24 +2,14 @@ from finat.citations import cite from finat.piola_mapped import PiolaBubbleElement -from finat.zany import PiolaPhysicallyMappedElement -class BernardiRaugel(PiolaPhysicallyMappedElement, PiolaBubbleElement): - """The Bernardi-Raugel element. - - The basis transformation is derived automatically from the FIAT - dual basis (see :class:`finat.zany.PiolaPhysicallyMappedElement`); - the trailing tangential facet constraints of the extended element - are dropped from the physical element by - :meth:`~finat.piola_mapped.PiolaBubbleElement.space_dimension`. - """ +class BernardiRaugel(PiolaBubbleElement): def __init__(self, cell, order=1, quad_scheme=None): cite("BernardiRaugel1985") super().__init__(FIAT.BernardiRaugel(cell, order=order, quad_scheme=quad_scheme)) class BernardiRaugelBubble(BernardiRaugel): - """The normal facet bubbles of the Bernardi-Raugel element.""" def __init__(self, cell, degree=None, quad_scheme=None): super().__init__(cell, order=0, quad_scheme=quad_scheme) diff --git a/finat/christiansen_hu.py b/finat/christiansen_hu.py index 018b1f6f0..c8b4babc0 100644 --- a/finat/christiansen_hu.py +++ b/finat/christiansen_hu.py @@ -2,18 +2,9 @@ from finat.citations import cite from finat.piola_mapped import PiolaBubbleElement -from finat.zany import PiolaPhysicallyMappedElement -class ChristiansenHu(PiolaPhysicallyMappedElement, PiolaBubbleElement): - """The Christiansen-Hu element. - - The basis transformation is derived automatically from the FIAT - dual basis (see :class:`finat.zany.PiolaPhysicallyMappedElement`); - the trailing tangential facet constraints of the extended element - are dropped from the physical element by - :meth:`~finat.piola_mapped.PiolaBubbleElement.space_dimension`. - """ +class ChristiansenHu(PiolaBubbleElement): def __init__(self, cell, degree=1): cite("ChristiansenHu2019") super().__init__(FIAT.ChristiansenHu(cell, degree)) diff --git a/finat/guzman_neilan.py b/finat/guzman_neilan.py index c3be03ee0..fc782147e 100644 --- a/finat/guzman_neilan.py +++ b/finat/guzman_neilan.py @@ -2,32 +2,17 @@ from finat.citations import cite from finat.piola_mapped import PiolaBubbleElement -from finat.zany import PiolaPhysicallyMappedElement -class GuzmanNeilanFirstKindH1(PiolaPhysicallyMappedElement, PiolaBubbleElement): - """Pk^d enriched with Guzman-Neilan bubbles. - - The basis transformation is derived automatically from the FIAT - dual basis (see :class:`finat.zany.PiolaPhysicallyMappedElement`); - the trailing tangential facet constraints of the extended element - are dropped from the physical element by - :meth:`~finat.piola_mapped.PiolaBubbleElement.space_dimension`. - """ +class GuzmanNeilanFirstKindH1(PiolaBubbleElement): + """Pk^d enriched with Guzman-Neilan bubbles.""" def __init__(self, cell, order=1, quad_scheme=None): cite("GuzmanNeilan2018") super().__init__(FIAT.GuzmanNeilanFirstKindH1(cell, order=order, quad_scheme=quad_scheme)) -class GuzmanNeilanSecondKindH1(PiolaPhysicallyMappedElement, PiolaBubbleElement): - """C0 Pk^d(Alfeld) enriched with Guzman-Neilan bubbles. - - The basis transformation is derived automatically from the FIAT - dual basis (see :class:`finat.zany.PiolaPhysicallyMappedElement`); - the trailing tangential facet constraints of the extended element - are dropped from the physical element by - :meth:`~finat.piola_mapped.PiolaBubbleElement.space_dimension`. - """ +class GuzmanNeilanSecondKindH1(PiolaBubbleElement): + """C0 Pk^d(Alfeld) enriched with Guzman-Neilan bubbles.""" def __init__(self, cell, order=1, quad_scheme=None): cite("GuzmanNeilan2018") super().__init__(FIAT.GuzmanNeilanSecondKindH1(cell, order=order, quad_scheme=quad_scheme)) @@ -39,15 +24,8 @@ def __init__(self, cell, degree=None, quad_scheme=None): super().__init__(cell, order=0, quad_scheme=quad_scheme) -class GuzmanNeilanH1div(PiolaPhysicallyMappedElement, PiolaBubbleElement): - """Alfeld-Sorokina nodally enriched with Guzman-Neilan bubbles. - - The basis transformation is derived automatically from the FIAT - dual basis (see :class:`finat.zany.PiolaPhysicallyMappedElement`); - the trailing tangential facet constraints of the extended element - are dropped from the physical element by - :meth:`~finat.piola_mapped.PiolaBubbleElement.space_dimension`. - """ +class GuzmanNeilanH1div(PiolaBubbleElement): + """Alfeld-Sorokina nodally enriched with Guzman-Neilan bubbles.""" def __init__(self, cell, degree=None, quad_scheme=None): cite("GuzmanNeilan2018") super().__init__(FIAT.GuzmanNeilanH1div(cell, degree=degree, quad_scheme=quad_scheme)) diff --git a/finat/hermite.py b/finat/hermite.py index a62a7cf4a..fd5f6d767 100644 --- a/finat/hermite.py +++ b/finat/hermite.py @@ -1,16 +1,32 @@ import FIAT +from gem import ListTensor from finat.citations import cite from finat.fiat_elements import ScalarFiatElement -from finat.zany import ScalarPhysicallyMappedElement +from finat.physically_mapped import identity, PhysicallyMappedElement -class Hermite(ScalarPhysicallyMappedElement, ScalarFiatElement): - """The cubic Hermite element. - - The basis transformation is derived automatically from the FIAT - dual basis; see :class:`finat.zany.ScalarPhysicallyMappedElement`. - """ +class Hermite(PhysicallyMappedElement, ScalarFiatElement): def __init__(self, cell, degree=3): cite("Ciarlet1972") super().__init__(FIAT.CubicHermite(cell)) + + def _basis_transformation(self, coordinate_mapping): + Js = [coordinate_mapping.jacobian_at(vertex) + for vertex in self.cell.get_vertices()] + + h = coordinate_mapping.cell_size() + + d = self.cell.get_dimension() + M = identity(self.space_dimension()) + + cur = 0 + for i in range(d+1): + cur += 1 # skip the vertex + J = Js[i] + for j in range(d): + for k in range(d): + M[cur+j, cur+k] = J[j, k] / h[i] + cur += d + + return ListTensor(M) diff --git a/finat/hz.py b/finat/hz.py index 2212d15d5..33e751dd2 100644 --- a/finat/hz.py +++ b/finat/hz.py @@ -1,18 +1,48 @@ """Implementation of the Hu-Zhang finite elements.""" import FIAT - +from gem import ListTensor from finat.citations import cite from finat.fiat_elements import FiatElement -from finat.zany import PiolaPhysicallyMappedElement - +from finat.physically_mapped import identity, PhysicallyMappedElement +from finat.aw import _facet_transform, _evaluation_transform -class HuZhang(PiolaPhysicallyMappedElement, FiatElement): - """The Hu-Zhang element. - The basis transformation is derived automatically from the FIAT - dual basis; see :class:`finat.zany.PiolaPhysicallyMappedElement`. - """ +class HuZhang(PhysicallyMappedElement, FiatElement): def __init__(self, cell, degree=3, variant=None, quad_scheme=None): cite("Hu2015") self.variant = variant super().__init__(FIAT.HuZhang(cell, degree, variant=variant, quad_scheme=quad_scheme)) + + def _basis_transformation(self, coordinate_mapping): + ndofs = self.space_dimension() + V = identity(ndofs) + + sd = self.cell.get_spatial_dimension() + W = _evaluation_transform(self.cell, coordinate_mapping) + + # Put into the right rows and columns. + V[0:3, 0:3] = V[3:6, 3:6] = V[6:9, 6:9] = W + ncomp = W.shape[0] + num_verts = sd+1 + cur = num_verts * ncomp + + Vsub = _facet_transform(self.cell, self.degree-2, coordinate_mapping) + fdofs = Vsub.shape[0] + V[cur:cur+fdofs, cur:cur+fdofs] = Vsub + cur += fdofs + + # internal DOFs + if self.variant == "point": + while cur < ndofs: + V[cur:cur+ncomp, cur:cur+ncomp] = W + cur += ncomp + + # RESCALING FOR CONDITIONING + h = coordinate_mapping.cell_size() + for e in range(num_verts): + V[:, ncomp*e:ncomp*(e+1)] *= 1/(h[e] * h[e]) + + # Note: that the edge DOFs are scaled by edge lengths in FIAT implies + # that they are already have the necessary rescaling to improve + # conditioning. + return ListTensor(V.T) diff --git a/finat/johnson_mercier.py b/finat/johnson_mercier.py index b1af74c49..7005cc93e 100644 --- a/finat/johnson_mercier.py +++ b/finat/johnson_mercier.py @@ -1,17 +1,29 @@ import FIAT +from gem import ListTensor +from finat.aw import _facet_transform from finat.citations import cite from finat.fiat_elements import FiatElement -from finat.zany import PiolaPhysicallyMappedElement +from finat.physically_mapped import identity, PhysicallyMappedElement -class JohnsonMercier(PiolaPhysicallyMappedElement, FiatElement): # symmetric matrix valued - """The Johnson-Mercier element. - - The basis transformation is derived automatically from the FIAT - dual basis; see :class:`finat.zany.PiolaPhysicallyMappedElement`. - """ +class JohnsonMercier(PhysicallyMappedElement, FiatElement): # symmetric matrix valued def __init__(self, cell, degree=1, variant=None, quad_scheme=None): cite("Gopalakrishnan2024") - super().__init__(FIAT.JohnsonMercier(cell, degree, variant=variant, - quad_scheme=quad_scheme)) + self._indices = slice(None, None) + super().__init__(FIAT.JohnsonMercier(cell, degree, variant=variant, quad_scheme=quad_scheme)) + + def _basis_transformation(self, coordinate_mapping): + numbf = self._element.space_dimension() + ndof = self.space_dimension() + + V = identity(numbf, ndof) + Vsub = _facet_transform(self.cell, 1, coordinate_mapping) + Vsub = Vsub[:, self._indices] + m, n = Vsub.shape + V[:m, :n] = Vsub + + # Note: that the edge DOFs are scaled by edge lengths in FIAT implies + # that they are already have the necessary rescaling to improve + # conditioning. + return ListTensor(V.T) diff --git a/finat/morley.py b/finat/morley.py index c9605841a..fbd8655f8 100644 --- a/finat/morley.py +++ b/finat/morley.py @@ -1,17 +1,83 @@ import FIAT +import numpy + +from gem import ListTensor, partial_indexed, Literal, Power from finat.citations import cite from finat.fiat_elements import ScalarFiatElement -from finat.zany import ScalarPhysicallyMappedElement +from finat.physically_mapped import identity, PhysicallyMappedElement + + +def morley_transform(cell, J, detJ, face): + adjugate = lambda A: ListTensor([[A[1, 1], -1*A[1, 0]], [-1*A[0, 1], A[0, 0]]]) + sd = cell.get_spatial_dimension() + thats = cell.compute_tangents(sd-1, face) + nhat = numpy.cross(*thats) + ahat = numpy.linalg.norm(nhat) + nhat /= numpy.dot(nhat, nhat) + Jn = J @ Literal(nhat) + Jt = J @ Literal(thats.T) + Gnt = Jn.T @ Jt + Gtt = Jt.T @ Jt + detG = Gtt[0, 0]*Gtt[1, 1] - Gtt[0, 1]*Gtt[1, 0] + area = Power(detG, Literal(0.5)) -class Morley(ScalarPhysicallyMappedElement, ScalarFiatElement): - """The Morley element on simplices of any dimension. + Bnn = detJ / area + Bnt = Gnt @ adjugate(Gtt) / detG + Bnn *= ahat + Bnt *= ahat + Bnt = (-1*(Bnt[0] + Bnt[1]), Bnt[0], Bnt[1]) + return Bnn, Bnt - The basis transformation is derived automatically from the FIAT - dual basis; see :class:`finat.zany.ScalarPhysicallyMappedElement`. - """ + +class Morley(PhysicallyMappedElement, ScalarFiatElement): def __init__(self, cell, degree=2): cite("Morley1971") cite("MingXu2006") super().__init__(FIAT.Morley(cell, degree=degree)) + + def _basis_transformation(self, coordinate_mapping): + sd = self.cell.get_spatial_dimension() + top = self.cell.get_topology() + # Jacobians at barycenter + bary, = self.cell.make_points(sd, 0, sd+1) + J = coordinate_mapping.jacobian_at(bary) + detJ = coordinate_mapping.detJ_at(bary) + V = identity(self.space_dimension()) + + offset = len(top[sd-2]) + if sd == 2: + pel = coordinate_mapping.physical_edge_lengths() + pts = coordinate_mapping.physical_tangents() + pns = coordinate_mapping.physical_normals() + for e in top[sd-1]: + s = offset + e + t = partial_indexed(pts, (e,)) + n = partial_indexed(pns, (e,)) + nhat = self.cell.compute_normal(e) + Jn = J @ Literal(nhat) + Bnn = Jn @ n + Bnt = Jn @ t + V[s, s] = Bnn + v = list(top[sd-1][e]) + V[s, v] = Bnt / pel[e] + V[s, v[0]] *= -1 + + else: + edges = self.cell.get_connectivity()[(sd-1, sd-2)] + for face in top[sd-1]: + Bnn, Bnt = morley_transform(self.cell, J, detJ, face) + fid = offset + face + V[fid, fid] = Bnn + V[fid, list(edges[face])] = Bnt + + # diagonal post-scaling to patch up conditioning + h = coordinate_mapping.cell_size() + for face in top[sd-1]: + s = offset + face + verts = top[sd-1][face] + havg = sum(h[v] for v in verts) / len(verts) + V[:, s] *= 1/havg + + return ListTensor(V.T) diff --git a/finat/mtw.py b/finat/mtw.py index aea10ebb5..4086fa68e 100644 --- a/finat/mtw.py +++ b/finat/mtw.py @@ -1,19 +1,42 @@ import FIAT +from math import comb +from gem import ListTensor from finat.citations import cite from finat.fiat_elements import FiatElement -from finat.zany import PiolaPhysicallyMappedElement +from finat.physically_mapped import identity, PhysicallyMappedElement +from finat.piola_mapped import normal_tangential_transform -class MardalTaiWinther(PiolaPhysicallyMappedElement, FiatElement): - """The Mardal-Tai-Winther element. - - The basis transformation is derived automatically from the FIAT - dual basis; see :class:`finat.zany.PiolaPhysicallyMappedElement`. - """ +class MardalTaiWinther(PhysicallyMappedElement, FiatElement): def __init__(self, cell, order=1): if cell.get_spatial_dimension() == 2: cite("Mardal2002") else: cite("Xie2008") super().__init__(FIAT.MardalTaiWinther(cell, order=order)) + + def _basis_transformation(self, coordinate_mapping): + sd = self.cell.get_spatial_dimension() + bary, = self.cell.make_points(sd, 0, sd+1) + J = coordinate_mapping.jacobian_at(bary) + detJ = coordinate_mapping.detJ_at(bary) + + V = identity(self.space_dimension()) + q = self._element.order + dimP1 = comb(1+sd-1, 1) + dimPq = comb(q+sd-1, q) + + entity_dofs = self.entity_dofs() + for f in sorted(entity_dofs[sd-1]): + Bnt, Btt = normal_tangential_transform(self.cell, J, detJ, f) + ndofs = entity_dofs[sd-1][f][:dimPq] + tdofs = entity_dofs[sd-1][f][dimPq:] + V[tdofs, tdofs] = Btt + if sd == 2: + V[tdofs, ndofs[0]] = Bnt + else: + V[tdofs[:-1], ndofs[0]] = Bnt + V[tdofs[-1], ndofs[1:dimP1]] = Bnt + + return ListTensor(V.T) diff --git a/finat/piola_mapped.py b/finat/piola_mapped.py index 00debd110..4db254ba4 100644 --- a/finat/piola_mapped.py +++ b/finat/piola_mapped.py @@ -1,21 +1,78 @@ -"""Reduced Piola-mapped elements with normal facet bubbles.""" -from copy import deepcopy +import numpy from finat.fiat_elements import FiatElement -from finat.physically_mapped import PhysicallyMappedElement +from finat.physically_mapped import adjugate, determinant, identity, PhysicallyMappedElement +from gem import Literal, ListTensor, Zero +from copy import deepcopy +from itertools import chain -class PiolaBubbleElement(PhysicallyMappedElement, FiatElement): - """Dof-reduction wrapper for Piola-mapped elements with normal facet bubbles. - - The FIAT element is an extended element carrying tangential facet - functionals as trailing constraints; this wrapper exposes only the - normal facet bubble on each facet, following the reduced/constrained - element convention (rectangular transformation, truncated to - :meth:`space_dimension` columns). The basis transformation itself is - provided by :class:`finat.zany.PiolaPhysicallyMappedElement`, which - concrete subclasses list first in their bases. +def piola_inverse(fiat_cell, J, detJ): + """Return the basis transformation of evaluation at a point. + This simply inverts the Piola transform inv(J / detJ) = adj(J).""" + sd = fiat_cell.get_spatial_dimension() + Jnp = numpy.array([[J[i, j] for j in range(sd)] for i in range(sd)]) + return adjugate(Jnp) + + +def normal_tangential_edge_transform(fiat_cell, J, detJ, f): + """Return the basis transformation of + normal and tangential edge moments""" + R = numpy.array([[0, 1], [-1, 0]]) + that = fiat_cell.compute_edge_tangent(f) + that /= numpy.linalg.norm(that) + nhat = R @ that + Jn = J @ Literal(nhat) + Jt = J @ Literal(that) + alpha = Jn @ Jt + beta = Jt @ Jt + # Compute the last row of inv([[1, 0], [alpha/detJ, beta/detJ]]) + return (-1 * alpha / beta, detJ / beta) + + +def normal_tangential_face_transform(fiat_cell, J, detJ, f): + """Return the basis transformation of + normal and tangential face moments""" + # Compute the reciprocal basis + thats = fiat_cell.compute_tangents(2, f) + nhat = numpy.cross(*thats) + nhat /= numpy.dot(nhat, nhat) + orths = numpy.cross(thats, nhat[None, :], axis=1) + + Jn = J @ Literal(nhat) + Jthats = J @ Literal(thats.T) + Jorths = J @ Literal(orths.T) + A = Jthats.T @ Jorths + B = Jn @ Jthats + A = numpy.array([[A[i, j] for j in range(A.shape[1])] for i in range(A.shape[0])]) + B = numpy.array([B[i] for i in range(B.shape[0])]) + + Q = numpy.dot(thats, thats.T) + beta = determinant(A) + alpha = Q @ (adjugate(A) @ B) + return (alpha / beta, detJ / beta) + + +def normal_tangential_transform(fiat_cell, J, detJ, f): + """Return the basis transformation of normal and tangential face moments + + :arg fiat_cell: a :class:`FIAT.reference_element.Cell` + :arg J: the Jacobian of the coordinate transformation + :arg detJ: the Jacobian determinant of the coordinate transformation + :arg f: the face id. + + :returns: a 2-tuple of (Bnt, Btt) where + Bnt is the numpy.ndarray of normal-tangential coefficients, and + Btt is the tangential-tangential coefficient. """ + if fiat_cell.get_spatial_dimension() == 2: + return normal_tangential_edge_transform(fiat_cell, J, detJ, f) + else: + return normal_tangential_face_transform(fiat_cell, J, detJ, f) + + +class PiolaBubbleElement(PhysicallyMappedElement, FiatElement): + """A general class to transform Piola-mapped elements with normal facet bubbles.""" def __init__(self, fiat_element): mapping, = set(fiat_element.mapping()) if mapping != "contravariant piola": @@ -40,3 +97,63 @@ def entity_dofs(self): def space_dimension(self): return self._space_dimension + + def _basis_transformation(self, coordinate_mapping): + sd = self.cell.get_spatial_dimension() + bary, = self.cell.make_points(sd, 0, sd+1) + J = coordinate_mapping.jacobian_at(bary) + detJ = coordinate_mapping.detJ_at(bary) + + dofs = self.entity_dofs() + bfs = self._element.entity_dofs() + numdof = self.space_dimension() + numbf = self._element.space_dimension() + V = identity(numbf, numdof) + + # Undo the Piola transform for non-facet bubble basis functions + nodes = self._element.get_dual_set().nodes + Finv = piola_inverse(self.cell, J, detJ) + for dim in dofs: + if dim == sd-1: + continue + for e in sorted(dofs[dim]): + k = 0 + while k < len(dofs[dim][e]): + cur = dofs[dim][e][k] + if len(nodes[cur].deriv_dict) > 0: + V[cur, cur] = detJ + k += 1 + else: + s = dofs[dim][e][k:k+sd] + V[numpy.ix_(s, s)] = Finv + k += sd + # Unpick the normal component for the facet bubbles + for f in sorted(dofs[sd-1]): + Bnt, Btt = normal_tangential_transform(self.cell, J, detJ, f) + ndof, *tdofs = dofs[sd-1][f] + nbf, *tbfs = bfs[sd-1][f] + V[tbfs, ndof] = Bnt + if len(tdofs) > 0: + V[tbfs, tdofs] = Btt + + # Fix discrepancy between normal and tangential moments + needs_facet_vertex_coupling = len(dofs[0][0]) > 0 and numbf > numdof + if needs_facet_vertex_coupling: + perp = lambda *t: numpy.array([t[0][1], -t[0][0]]) if len(t) == 1 else numpy.cross(*t) + + dim = max(d for d in range(sd-1) if len(dofs[d][0]) > 0) + vdofs = chain.from_iterable(dofs[dim].values()) + vdofs = [i for i in vdofs if nodes[i].max_deriv_order == 0] + fdofs = list(chain.from_iterable(dofs[sd-1].values())) + + T = numpy.full((len(fdofs), len(vdofs)), Zero(), dtype=object) + for f in sorted(dofs[sd-1]): + nhat = perp(*self.cell.compute_tangents(sd-1, f)) + Tfv = ((-1/sd) * nhat) @ Finv + for v in self.cell.connectivity[(sd-1, dim)][f]: + curvdofs = [vdofs.index(i) for i in dofs[dim][v] if i in vdofs] + for fdof in dofs[sd-1][f]: + T[fdofs.index(fdof), curvdofs] = Tfv + + V[numdof:, vdofs] += V[numdof:, fdofs] @ T + return ListTensor(V.T) diff --git a/finat/walkington.py b/finat/walkington.py index c041b24ff..dc599bbec 100644 --- a/finat/walkington.py +++ b/finat/walkington.py @@ -2,39 +2,17 @@ import numpy from FIAT.polynomial_set import mis -from gem import ListTensor, Literal, Power, Zero +from gem import ListTensor, Zero from finat.citations import cite from finat.fiat_elements import ScalarFiatElement from finat.physically_mapped import identity, PhysicallyMappedElement from finat.argyris import _vertex_transform, _normal_tangential_transform +from finat.morley import morley_transform from copy import deepcopy from itertools import chain -def morley_transform(cell, J, detJ, face): - adjugate = lambda A: ListTensor([[A[1, 1], -1*A[1, 0]], [-1*A[0, 1], A[0, 0]]]) - sd = cell.get_spatial_dimension() - thats = cell.compute_tangents(sd-1, face) - nhat = numpy.cross(*thats) - ahat = numpy.linalg.norm(nhat) - nhat /= numpy.dot(nhat, nhat) - - Jn = J @ Literal(nhat) - Jt = J @ Literal(thats.T) - Gnt = Jn.T @ Jt - Gtt = Jt.T @ Jt - detG = Gtt[0, 0]*Gtt[1, 1] - Gtt[0, 1]*Gtt[1, 0] - area = Power(detG, Literal(0.5)) - - Bnn = detJ / area - Bnt = Gnt @ adjugate(Gtt) / detG - Bnn *= ahat - Bnt *= ahat - Bnt = (-1*(Bnt[0] + Bnt[1]), Bnt[0], Bnt[1]) - return Bnn, Bnt - - class Walkington(PhysicallyMappedElement, ScalarFiatElement): def __init__(self, cell, degree=5): cite("Walkington2010") @@ -46,7 +24,7 @@ def __init__(self, cell, degree=5): reduced_dofs[sd-1][entity] = reduced_dofs[sd-1][entity][:1] self._entity_dofs = reduced_dofs - def basis_transformation(self, coordinate_mapping): + def _basis_transformation(self, coordinate_mapping): # Jacobian at barycenter sd = self.cell.get_spatial_dimension() top = self.cell.get_topology() diff --git a/finat/wuxu.py b/finat/wuxu.py index 542e20a46..9b519ad00 100644 --- a/finat/wuxu.py +++ b/finat/wuxu.py @@ -1,6 +1,5 @@ from finat.fiat_elements import ScalarFiatElement -from finat.physically_mapped import identity -from finat.zany import ScalarPhysicallyMappedElement +from finat.physically_mapped import identity, PhysicallyMappedElement from finat.argyris import _vertex_transform from finat.citations import cite from gem import ListTensor @@ -9,25 +8,25 @@ import numpy -class WuXuRobustH3NC(ScalarPhysicallyMappedElement, ScalarFiatElement): +class WuXuRobustH3NC(PhysicallyMappedElement, ScalarFiatElement): def __init__(self, cell, degree=7): if degree != 7: raise ValueError("Degree must be 7 for robust Wu-Xu element") cite("WuXu2019") super().__init__(FIAT.WuXuRobustH3NC(cell)) - def basis_transformation(self, coordinate_mapping): + def _basis_transformation(self, coordinate_mapping): return wuxu_transformation(self, coordinate_mapping) -class WuXuH3NC(ScalarPhysicallyMappedElement, ScalarFiatElement): +class WuXuH3NC(PhysicallyMappedElement, ScalarFiatElement): def __init__(self, cell, degree=4): if degree != 4: raise ValueError("Degree must be 4 for the Wu-Xu element") cite("WuXu2019") super().__init__(FIAT.WuXuH3NC(cell)) - def basis_transformation(self, coordinate_mapping): + def _basis_transformation(self, coordinate_mapping): return wuxu_transformation(self, coordinate_mapping)