projection: unit-aware smoothing_length API on the four Projection classes#200
projection: unit-aware smoothing_length API on the four Projection classes#200lmoresi wants to merge 2 commits into
Conversation
…asses
The L2-projection solvers solve
u - ∇·(α ∇u) = ũ
i.e. a screened-Poisson smoother whose Green's function decays as
exp(-r/L) with L = √α — equivalent in effect to a Gaussian convolution
of width L, obtained by one elliptic solve without ever forming the
kernel.
`smoothing` (= α, units length²) was the only existing knob and is
historically used as a tiny *numerical* regulariser (e.g. 1e-6), which
gives sub-grid L and no physical smoothing. This change adds a
parallel L-valued, unit-aware `smoothing_length` accessor on:
- SNES_Projection
- SNES_Vector_Projection
- SNES_Tensor_Projection (inherits)
- SNES_MultiComponent_Projection
Setter accepts a plain float, a Pint Quantity with length units, or
any unit-aware object understood by uw.non_dimensionalise; the value
is non-dimensionalised, squared and stored in `self._smoothing`, so
the two views stay consistent. Getter returns a Pint Quantity in
length units when a scaling context is set, else the plain ND float.
Class docstrings have been rewritten to put `smoothing_length` in
its mathematical context (screened-Poisson form, Green's function,
attenuation as 1/(1+k²L²), guidance on choosing L relative to the
cell size). Vector / Tensor / MultiComponent classes defer the full
derivation to SNES_Projection.
A new tests/test_0505_projection_smoothing_length.py locks:
- L → α = L² round-trip on all four classes
- α → smoothing_length = √α round-trip
- End-to-end: smoothing a step function at increasing L shrinks
the peak-to-peak range monotonically (screened-Poisson low-pass
behaviour)
Existing projection tests pass unchanged.
Underworld development team with AI support from Claude Code
There was a problem hiding this comment.
Pull request overview
This PR adds a new, physically meaningful unit-aware smoothing_length accessor across the Projection solver family, intended as a length-scale view of the existing smoothing (α) coefficient with the convention α = smoothing_length². It also expands class/property docstrings to document the screened-Poisson interpretation and introduces a new test module to lock the API.
Changes:
- Adds
smoothing_lengthproperty (getter/setter) toSNES_Projection,SNES_Vector_Projection, andSNES_MultiComponent_Projection(and therefore toSNES_Tensor_Projectionvia inheritance), keepingsmoothingandsmoothing_lengthconsistent. - Updates projection class/property docstrings with a screened-Poisson / Helmholtz derivation and guidance on choosing the filter scale.
- Adds a new test module to check round-trips and end-to-end monotone smoothing behavior.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
src/underworld3/systems/solvers.py |
Adds smoothing_length API and substantial docstring updates for projection solvers. |
tests/test_0505_projection_smoothing_length.py |
New tests for round-trip behavior and a basic end-to-end smoothing monotonicity check. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| except Exception: | ||
| # Fall back to magnitude-or-float coercion if the | ||
| # value doesn't carry/expect units. | ||
| if hasattr(L, "magnitude"): | ||
| L_nd = L.magnitude | ||
| else: | ||
| L_nd = L | ||
| self._smoothing = sympify(L_nd) ** 2 | ||
|
|
| except (TypeError, ValueError): | ||
| return sympy.sqrt(s) | ||
| if sval < 0: | ||
| return None |
| def test_scalar_smoothing_length_roundtrip(mesh): | ||
| """L=0.05 ⇒ α=L²=0.0025; reading back gives L (unit-aware).""" | ||
| V = uw.discretisation.MeshVariable( | ||
| "V_sl", mesh, vtype=uw.VarType.SCALAR, degree=2, continuous=True) | ||
| proj = sys.Projection(mesh, V) | ||
| proj.smoothing_length = 0.05 | ||
| assert float(proj.smoothing) == pytest.approx(0.0025) | ||
| L = proj.smoothing_length | ||
| # In an active scaling context this is a Pint Quantity in metres; | ||
| # without one it's a plain float — both should round-trip. | ||
| L_num = getattr(L, "magnitude", L) | ||
| assert float(L_num) == pytest.approx(0.05) |
| self._smoothing = sympify(L_nd) ** 2 | ||
|
|
| try: | ||
| L_nd = uw.non_dimensionalise(L) | ||
| except Exception: | ||
| if hasattr(L, "magnitude"): | ||
| L_nd = L.magnitude | ||
| else: | ||
| L_nd = L | ||
| self._smoothing = sympify(L_nd) ** 2 | ||
|
|
The initial smoothing_length API had two coupled bugs that produced the five CI failures on PR #200: 1. Asymmetric round-trip. The setter stored the input as-is (plain float treated as already non-dim) but the getter unconditionally called uw.scaling.dimensionalise(L_nd, meter). Under an active scaling context (default ~Earth scale), plain-float input 0.05 read back as 145000 m. 2. Silently broken for unit-bearing input. uw.non_dimensionalise(L) returns a UWQuantity that refuses sympify, so the previous try/except fell back to L.magnitude — i.e. the dimensional magnitude — and stored it as if it were non-dim, completely ignoring the active scale. Fix: track whether the user supplied a dimensional value in _smoothing_is_dimensional; getter returns a Pint Quantity in metres iff that flag is set; otherwise returns the plain non-dim float that was stored. The setter explicitly extracts .magnitude from the UWQuantity returned by non_dimensionalise, so Pint input now round-trips correctly across the active scaling context. Also addresses Copilot's other review notes: - Negative smoothing_length now raises ValueError instead of the getter silently returning None. - New test_smoothing_length_pint_quantity_roundtrip exercises a Pint Quantity under uw.Model + use_nondimensional_scaling, asserts α = (0.05/1000)² = 2.5e-9 and Pint round-trip. - New test_smoothing_length_negative_raises locks the validation. Fix applied to all three concrete setter/getter sites (SNES_Projection, SNES_Vector_Projection, SNES_MultiComponent_Projection); SNES_Tensor_Projection inherits. All 8 tests in test_0505_projection_smoothing_length.py pass. Underworld development team with AI support from Claude Code
Fix pushed —
|
| # | Concern | Disposition |
|---|---|---|
| 1 | non_dimensionalise → sympify fails for Pint Quantity |
Fixed — extract .magnitude from UWQuantity before squaring |
| 2 | Getter returns None for negative smoothing |
Fixed — setter validates, getter raises |
| 3 | No unit-bearing test | Fixed — new test_smoothing_length_pint_quantity_roundtrip uses uw.Model + use_nondimensional_scaling(True) + uw.quantity(0.05, "m"), asserts α = (0.05/1000)² = 2.5e-9 and Pint round-trip |
| 4 | Same issue in vector projector | Fixed |
| 5 | Same issue in multi-component projector | Fixed |
Local verification
$ pixi run -e runtime python -m pytest -v tests/test_0505_projection_smoothing_length.py
test_scalar_smoothing_length_roundtrip PASSED
test_vector_smoothing_length_roundtrip PASSED
test_tensor_smoothing_length_roundtrip PASSED
test_multicomponent_smoothing_length_roundtrip PASSED
test_smoothing_setter_keeps_alpha_view PASSED
test_smoothing_length_pint_quantity_roundtrip PASSED ← new
test_smoothing_length_negative_raises PASSED ← new
test_smoothing_length_smoothes_a_step PASSED
8 passed in 8.54s
Underworld development team with AI support from Claude Code
|
Thanks for this @lmoresi, I'll give it a try |
@benknight — this is the bit you asked me about: a length-scale-aware way to set the smoother on
Projection(and friends) instead of the unit-lesssmoothingknob. Splitting it out of a larger meshing branch so it can land independently.Summary
Adds an L-valued, unit-aware
smoothing_lengthaccessor to the four projection classes:SNES_ProjectionSNES_Vector_ProjectionSNES_Tensor_ProjectionSNES_MultiComponent_ProjectionThe existing
smoothingknob is preserved unchanged (units of length², historically used as a tiny numerical regulariser);smoothing_lengthis a parallel view with the physical conventionsmoothing = smoothing_length². The two stay consistent: setting one updates both.Why — the mathematics
The L2-projection solvers all minimise a Tikhonov-regularised misfit
whose Euler–Lagrange equation is the screened-Poisson equation
The free-space Green's function of$\bigl(1 - \alpha\nabla^2\bigr)$ decays as $\exp(-r/L)$ where
so the projection acts as a Gaussian-like convolution of width$L$ , obtained implicitly by one elliptic solve — no kernel is ever assembled. In Fourier space the transfer function is
so features at scale$\ll L$ are damped, features at $\gg L$ pass through, and features at $\sim L$ are attenuated by ≈ 1/2.
This makes$L$ — not $\alpha$ — the natural physical knob: you ask for "smooth this field at scale $L$ " and the solver delivers it.
API
Identical surface on
Vector_Projection,Tensor_Projection, andMultiComponent_Projection. The class docstrings now carry the screened-Poisson derivation (with guidance:L ≳ his needed for any real filtering;L ≈ 1–2·his a useful light de-noising default).Test plan
tests/test_0505_projection_smoothing_length.pyis new and locks:L → α = L²round-trip on all four classesα → smoothing_length = √αround-tripLmonotonically shrinks the projected field's peak-to-peak range (the screened-Poisson low-pass behaviour the docstring promises)tests/test_0504_projections.pyandtests/test_multicomponent_projection.pypass unchangedUnderworld development team with AI support from Claude Code