Skip to content

Implement tensor evaluate path in _project_to_work_variable (#180 followup)#185

Open
lmoresi wants to merge 1 commit into
developmentfrom
bugfix/project-work-var-vtype
Open

Implement tensor evaluate path in _project_to_work_variable (#180 followup)#185
lmoresi wants to merge 1 commit into
developmentfrom
bugfix/project-work-var-vtype

Conversation

@lmoresi
Copy link
Copy Markdown
Member

@lmoresi lmoresi commented May 13, 2026

Summary

Replaces dead matrix-projection code in _project_to_work_variable (_function.pyx) so uw.function.evaluate works directly on tensor expressions containing derivatives (e.g. strain rate, NS viscous flux). Follow-on to #183 — together they fix issue #180 from both sides:

The bugs (both marked by a pre-existing TODO(BUG))

  1. MeshVariable(num_components=rows*cols) (flat int) — base class can't infer vtype, raises ValueError on every call. This is what Mohammed sees in his trace.
  2. SNES_Projection(scalar_component=c) — that keyword doesn't exist on SNES_Projection.__init__; the loop would have TypeError if it ever reached.

So the entire matrix branch was unreachable. Every tensor-evaluate call (e.g. evaluate(NS.flux, coords)) bounced out via bug 1 and callers like SemiLagrangian.update_pre_solve fell back to their own projector.

Fix

Replaces the dead branch with a real multi-component projection that mirrors the pattern already used in DDt._setup_projections:

  • Cache a flat (1, Nc) MATRIX work var as the projector target.
  • Use SNES_MultiComponent_Projection (one solve, shared DM) instead of per-component cycling.
  • Cache a tensor-shaped (rows, cols) work var so the caller's work_var.sym keeps the original shape.
  • After solve, fan flat var values into the tensor work var.

Tests

tests/test_0506_tensor_evaluate.py (3 tests):

  • evaluate(strain_tensor, pts) returns shape (n_pts, dim, dim) — rotation field gives zero strain (correct).
  • Simple shear field produces the expected off-diagonal strain entries ~0.5.
  • A NavierStokes solve completes without entering the DDt fallback.

Test plan

  • pytest tests/test_0506_tensor_evaluate.py — 3 passed
  • pytest tests/test_0503_evaluate.py tests/test_0503_evaluate2.py tests/test_0504_projections.py — 18 passed (no regression on existing evaluate paths)
  • Instrumented trace confirms _project_to_work_variable is now reached and succeeds with (2, 2) shaped tensors on a NS solve
  • Reviewer to confirm the cache scheme (mesh._eval_work_<rows>x<cols> etc.) is OK for long-running models

Underworld development team with AI support from Claude Code

_project_to_work_variable's matrix-expression branch was dead code with
two bugs marked by a TODO:

  1. MeshVariable(num_components=rows*cols) (flat int) couldn't infer
     vtype, raising ValueError on every call.
  2. SNES_Projection(scalar_component=c) — that kwarg doesn't exist on
     SNES_Projection.__init__; the loop would have raised TypeError if
     it ever reached.

So evaluate(strain_tensor, pts) — or any tensor expression containing
derivatives — bounced out via the ValueError, and callers like
SemiLagrangian.update_pre_solve hit their outer ``except Exception``
and fell back to their own projection solver. After PR #183 the DDt
fallback works correctly, but the wasted try-fail-fallback was running
every NS step and the spurious ValueError polluted stack traces.

Replace the dead block with a real multi-component projection:
- Cache a flat (1, Nc) MATRIX work var as the projector target (mirrors
  the existing pattern in DDt._setup_projections for SYM_TENSOR/TENSOR).
- Use SNES_MultiComponent_Projection (one solve, shared DM) instead of
  a per-component cycling loop.
- Cache a tensor-shaped (rows, cols) work var so the caller's
  ``work_var.sym`` preserves the input expression's shape.
- After solve, fan flat var values into the tensor work var.

Adds tests/test_0506_tensor_evaluate.py covering:
- evaluate(strain_tensor, pts) returns (n_pts, dim, dim) shape
- A known shear field produces the expected non-zero strain entries
- A NavierStokes solve completes without entering the DDt fallback

Related: issue #180, PR #183 (which fixes the fallback path so it works
correctly when other expressions still hit it).

Underworld development team with AI support from Claude Code
Copilot AI review requested due to automatic review settings May 13, 2026 23:04
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes the tensor/matrix projection path used by uw.function.evaluate() when evaluating expressions containing derivatives, by replacing previously unreachable/broken code in _project_to_work_variable with a working multi-component projection approach. It also adds regression tests for tensor evaluation (strain tensor) and a NavierStokes solve scenario related to issue #180.

Changes:

  • Replace dead/broken matrix-projection logic in _project_to_work_variable with a SNES_MultiComponent_Projection-based implementation using a flat (1, Nc) work variable and a tensor-shaped result variable.
  • Cache tensor-shaped, flat, and projector objects on the mesh keyed by tensor shape.
  • Add new tests covering tensor-valued derivative evaluation and a NavierStokes solve regression.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src/underworld3/function/_function.pyx Implements working tensor/matrix projection via a cached flat (1, Nc) multicomponent projector plus fan-out to a tensor-shaped work variable.
tests/test_0506_tensor_evaluate.py Adds regression tests for tensor evaluate shape/values and a NavierStokes solve scenario tied to the derivative-evaluate projection path.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +607 to +611
# Build flat row-matrix source: [[expr[0,0], expr[0,1], ..., expr[r-1,c-1]]]
flat_source = sympy.Matrix(
[[expr[i, j] for i in range(rows) for j in range(cols)]]
)
projector.uw_function = flat_source
Comment on lines +78 to +83
def test_navier_stokes_solve_does_not_trigger_ddt_fallback():
"""A NavierStokes solve completes without entering the DDt projection fallback.

The DDt fallback in ``update_pre_solve`` exists for expressions where
``uw.function.evaluate`` raises. Once tensor evaluate works, the
fallback should not be needed for the NS viscous flux.
@lmoresi
Copy link
Copy Markdown
Member Author

lmoresi commented May 14, 2026

Review pass — ready to merge

Scope: _project_to_work_variable was effectively dead code for matrix expressions — the matrix branch tried MeshVariable(num_components=rows*cols) (a flat int), which can't infer vtype, so every call raised ValueError and bounced into DDt.update_pre_solve's outer except Exception clause. That made the NS solver pay a try-fail-fallback cost on every step. This PR makes the matrix branch actually work, using SNES_MultiComponent_Projection against a flat (1, Nc) MATRIX var and fanning the result back into a (rows, cols) tensor work-var so the caller's work_var.sym preserves the original shape.

Verification:

  • Shape detection: if rows == mesh.dim and cols == mesh.dim → vtype_out = TENSOR is the right default; non-square stays MATRIX.
  • Flat→tensor fan-out indexing: idx = i*cols + j matches the comprehension order in flat_source, so divmod(idx, cols) → (i, j) is correct.
  • Cache key _eval_work_{rows}x{cols} is shape-aware, so different tensors on the same mesh don't collide.
  • Tests at three levels: shape-correctness on zero-strain rotation, value-correctness on simple shear, and the end-to-end NS solve.

Copilot's two notes:

  1. Symmetric tensors are projected redundantly — true but optional. Current code projects all rows*cols independently and then writes both off-diagonals; for a symmetric source expr == expr.T the noise is bounded by the projection tolerance (1e-6). Worth a follow-up only if the SYM_TENSOR path shows measurable benefit.
  2. Test name asserts the fallback isn't triggered but only checks solve() completes — fair point. Worth either monkeypatching the fallback to assert non-entry, or renaming to test_navier_stokes_solve_with_tensor_evaluate_path. Not blocking.

Together with #183, this closes out #180. #183 hardens the fallback path; this PR removes the reason the fallback gets entered. Belt + braces.

LGTM modulo standard maintainer eyeball.

Underworld development team with AI support from Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants