From 1ed2c1f7ee2564f501bd4ec857ff0acd65e24750 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sat, 26 Sep 2026 13:24:20 -0700 Subject: [PATCH 01/21] Forward semi-Lagrangian history launched from the field's nodes and element interiors Forward from where the values are known best. A field is known exactly at its own nodes and, since its interpolant is a polynomial inside each element, at any interior point. ForwardNodesSemiLagrangian launches the values at the nodes and at the interior lattice of every element (the discontinuous basis one degree up), carries them one step forward on the shared characteristic trace, fits the arrivals in each cell at the field's own degree (the cell polynomial projector, with its thin-cell and conditioning fallbacks) and reads the fit back at the nodes. The fit never crosses an element boundary, where the interpolant has a kink; a neighbourhood fit across cells was tried first and smoothed (disc error 3.6e-2). As the DuDt of the SLCN advection-diffusion solver on the rotating diffusing Gaussian (P2 temperature, dt 0.02, kappa 0.01): half revolution on the disc 2.00e-2 (backward nodal history 2.52e-2, swarm 2.04e-2, SUPG 1.72e-2), peak 0.1873 against 0.1865 exact; the square box, where the flow crosses all four walls, 6.17e-2 (backward 6.43e-2); a full revolution 7.63e-2 (backward 8.94e-2, SUPG and swarm 8.2e-2). About twice the backward cost per step. First order, serial (the fit near a partition seam needs the other rank's arrivals). Test test_1102: disc and box, hard baselines. Underworld development team with AI support from Claude Code Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo --- src/underworld3/systems/ddt.py | 186 ++++++++++++++++++ ...st_1102_forward_nodes_rotating_gaussian.py | 54 +++++ 2 files changed, 240 insertions(+) create mode 100644 tests/test_1102_forward_nodes_rotating_gaussian.py diff --git a/src/underworld3/systems/ddt.py b/src/underworld3/systems/ddt.py index b21f6a021..148da5f3b 100644 --- a/src/underworld3/systems/ddt.py +++ b/src/underworld3/systems/ddt.py @@ -5766,3 +5766,189 @@ def commit_flux_to_history(self, flux, verbose=False): self._launch_values = self._evaluate_at_launch(flux) self._fit_arrivals(self._launch, self._launch_values, cell=self._launch_cell) self._history_committed = True + + +@dataclass +class DDtForwardNodesState(_DDtCoreState): + """Snapshot of a :class:`ForwardNodesSemiLagrangian` instance.""" + psi_star_var_names: list[str] = field(default_factory=list) + + +class ForwardNodesSemiLagrangian(_DDtBase): + r"""Forward semi-Lagrangian history launched from where the field is known. + + A field is known exactly at its own nodes (they are its unknowns) and, since + its interpolant is a polynomial inside each element, at any point of an + element's interior. Each step launches the values at the nodes and at a + lattice inside every element (the points of the discontinuous basis one + degree up), carries each one step forward along the characteristic, fits + the arrivals in each cell to a polynomial of the field's own degree, and + reads that fit back at the nodes. The fit never reaches across an element + boundary, where the interpolant has a kink. A cell with too few arrivals, or + arrivals on a line, falls back to a linear fit over the nearest arrivals; a + cell nothing reached keeps its previous fit (see + :class:`~underworld3.utilities.cell_polynomial_projection.CellPolynomialProjector`). + Arrivals that leave the domain are dropped; a node the flow reached from + outside takes :attr:`inflow_value` when one is set. + + The integration-point counterpart is :class:`ForwardSemiLagrangian`, which + launches from the quadrature points, where a flux (a stress) is formed. + First order; serial. + + Parameters + ---------- + mesh : Mesh + psi_fn : sympy expression or matrix + The carried quantity, for example ``T.sym``. + V_fn : sympy matrix + The velocity that carries it. + vtype : VarType + degree : int + Degree of the store and of the per-cell fit; use the field's own degree. + units : optional + Units of the carried quantity (see :func:`_history_units`). + """ + + applies_inflow_value = True + instances = 0 + + def __init__(self, mesh, psi_fn, V_fn, vtype=VarType.SCALAR, degree=1, varsymbol=None, + order=1, theta=0.5, units=None, **_unsupported): + super().__init__() + if order != 1: + raise NotImplementedError("ForwardNodesSemiLagrangian carries one level; order must be 1") + if mesh.cdim != mesh.dim: + raise NotImplementedError("ForwardNodesSemiLagrangian fits in the embedding coordinates; no manifolds") + if uw.mpi.size > 1: + raise NotImplementedError("ForwardNodesSemiLagrangian is serial: the fit at a node near a " + "partition seam needs the arrivals on the other rank") + if _unsupported: + warnings.warn(f"ForwardNodesSemiLagrangian ignores {sorted(_unsupported)}", stacklevel=2) + self.vtype = vtype + self.mesh = mesh + self.degree = int(degree) + self.continuous = True + self.order = 1 + self.theta = float(theta) + self.V_fn = V_fn + self._psi_fn = psi_fn if isinstance(psi_fn, sympy.Matrix) else sympy.Matrix([[psi_fn]]) + self._init_history_tracking(1) + if varsymbol is None: + varsymbol = rf"u_{{ [{self.instance_number}] }}" + self.psi_star = [ + uw.discretisation.MeshVariable( + f"psi_star_fwn_{self.instance_number}", mesh, vtype=vtype, degree=self.degree, + continuous=True, varsymbol=rf"{{ {varsymbol}^{{ * }} }}", + units=_history_units(self._psi_fn, units)) + ] + self._components = _storage_components(vtype, tuple(self.psi_star[0].sym.shape)) + # the per-cell fit: a discontinuous variable of the field's degree, and + # the interior lattice the values are also launched from + from underworld3.utilities.cell_polynomial_projection import CellPolynomialProjector + self._fit_var = uw.discretisation.MeshVariable( + f"fit_fwn_{self.instance_number}", mesh, vtype=vtype, degree=self.degree, continuous=False) + self._projector = CellPolynomialProjector(self._fit_var) + self._interior = np.asarray(mesh._get_coords_for_basis(self.degree + 1, continuous=False) + ).reshape(-1, mesh.cdim) + self._fit = None + self._n_v = 2 + self._init_coefficient_expressions(1, self.theta, with_exp=True) + self._register_with_default_model() + + @property + def psi_fn(self): + return self._psi_fn + + @psi_fn.setter + def psi_fn(self, new_fn): + self._psi_fn = new_fn if isinstance(new_fn, sympy.Matrix) else sympy.Matrix([[new_fn]]) + + @property + def state(self) -> "DDtForwardNodesState": + return DDtForwardNodesState( + **self._core_state_kwargs(), + psi_star_var_names=[ps.clean_name for ps in self.psi_star], + ) + + @state.setter + def state(self, s: "DDtForwardNodesState") -> None: + self._validate_state_schema(s, DDtForwardNodesState) + self._validate_psi_star_names(s.psi_star_var_names) + self._restore_core_state(s, am_theta=self.theta) + + # ------------------------------------------------------------------ + def _nodes(self): + return np.asarray(_to_nondim_ndarray(self.psi_star[0].coords)).reshape(-1, self.mesh.cdim) + + def _values_at(self, expr, X): + """``expr`` at the points ``X``, one column per stored component.""" + expr = sympy.Matrix(expr) + return np.column_stack([ + np.asarray(_to_nondim_ndarray(uw.function.evaluate(expr[i, j], X))).reshape(-1) + for (i, j) in self._components]) + + def _reconstruct(self, arrivals, values, nodes): + """Fit the arrivals in each cell at the field's degree; the fit at the nodes.""" + inside = np.asarray(self.mesh.points_in_domain(arrivals), dtype=bool) + self._fit = self._projector.fit(arrivals[inside], values[inside], old=self._fit) + return np.nan_to_num(self._projector.interpolate(self._fit, nodes)) + + # ------------------------------------------------------------------ + def initialise_history(self): + """Start from the current field at the nodes. A history already placed + by :meth:`commit_flux_to_history` is the start, and is kept.""" + self.characteristics.initialise_levels(self._n_v) + if not self._history_committed: + self.psi_star[0].data[:, :] = self._values_at(self._psi_fn, self._nodes()) + self._history_initialised = True + + def update_pre_solve(self, dt, evalf=False, verbose=False, store_result=True, **_ignored): + """Carry the field forward one step from the nodes and rebuild it there. + + ``store_result=False`` says the store already holds the values to launch + (placed by :meth:`commit_flux_to_history`); otherwise the tracked field + is read at the nodes first. + """ + self._dt = dt = self._nondim_timestep(dt) + if not self._history_initialised: + self.initialise_history() + _update_bdf_values(self._bdf_coeffs, self.effective_order, self._dt, self._dt_history) + _update_am_values(self._am_coeffs, self.effective_order, self.theta) + trace = self.characteristics + if self._owns_characteristics: + trace.begin_step(dt) + nodes = self._nodes() + launch = np.vstack([nodes, self._interior]) + # the tracked field, or (when a flux was committed) the store, which is + # a polynomial inside each element, so its interior values are exact + source = self._psi_fn if store_result else self.psi_star[0].sym + values = np.vstack([self._values_at(source, nodes) if store_result else np.array(self.psi_star[0].data), + self._values_at(source, self._interior)]) + key = (_basis_key_of(self.psi_star[0]), "launch") + arrivals = np.asarray(trace.departure_points(key, launch, (("first", 0, -float(dt)),), + evalf=evalf, clamp_final=False)) + self.psi_star[0].data[:, :] = self._reconstruct(arrivals, values, nodes) + arrivals = arrivals[:nodes.shape[0]] + if self._inflow_value is not None: + # a node whose back-trace leaves the domain holds fluid that entered this step + back = 2.0 * nodes - arrivals + restored = np.asarray(self.mesh.return_coords_to_bounds(back.copy())).reshape(back.shape) + entered = np.any(restored != back, axis=1) + if entered.any(): + self._write_inflow(self.psi_star[0], nodes, entered) + if self._owns_characteristics: + trace.finish_step() + + def update(self, dt, evalf=False, verbose=False, **kwargs): + self.update_pre_solve(dt, evalf=evalf, verbose=verbose, **kwargs) + + def update_post_solve(self, dt, evalf=False, verbose=False, **_ignored): + self._dt = dt = self._nondim_timestep(dt) + self._dt_history[0] = dt + if self._n_solves_completed < self.order: + self._n_solves_completed += 1 + + def commit_flux_to_history(self, flux, verbose=False): + """Read the new flux at the nodes and leave it in the store until the next carry.""" + self.psi_star[0].data[:, :] = self._values_at(flux, self._nodes()) + self._history_committed = True diff --git a/tests/test_1102_forward_nodes_rotating_gaussian.py b/tests/test_1102_forward_nodes_rotating_gaussian.py new file mode 100644 index 000000000..fe2320679 --- /dev/null +++ b/tests/test_1102_forward_nodes_rotating_gaussian.py @@ -0,0 +1,54 @@ +"""The forward-from-nodes history on the rotating diffusing Gaussian. + +Launched from the field's own nodes and from a lattice inside every element +(the field's interpolant is a polynomial there, so both are known exactly), +carried one step forward, fitted per cell at the field's degree and read back at +the nodes. Used as the DuDt of the SLCN advection-diffusion solver, half a +revolution, kappa 0.01, dt 0.02, P2 temperature, against the test_1101 fixture: +the backward nodal history gives 2.52e-2 on the disc and 6.43e-2 on the box +(where the flow crosses all four walls). +""" + +import numpy as np +import pytest +import sympy + +import underworld3 as uw +from test_1101_advdiff_swarm_rotating_gaussian import SIGMA, KAPPA, T_END, EXACT_PEAK, _disc + +pytestmark = [pytest.mark.level_2, pytest.mark.tier_b] + + +def _run(mesh, walls): + x, y = mesh.X + sol = uw.analytic.RotatingGaussian(mesh, sigma=SIGMA, centre_radius=0.5, omega=1.0, diffusivity=KAPPA) + T = uw.discretisation.MeshVariable("T", mesh, 1, degree=2) + T.array[:, 0, 0] = uw.function.evaluate(sol.at(0.0), T.coords).reshape(-1) + V = sympy.Matrix([[-y, x]]) + duDt = uw.systems.ddt.ForwardNodesSemiLagrangian(mesh, T.sym, V, vtype=uw.VarType.SCALAR, degree=T.degree) + adv = uw.systems.AdvDiffusionSLCN(mesh, u_Field=T, V_fn=V, DuDt=duDt, order=1) + adv.constitutive_model = uw.constitutive_models.DiffusionModel + adv.constitutive_model.Parameters.diffusivity = KAPPA + for wall in walls: + adv.add_dirichlet_bc(0.0, wall) + dt = 0.02 + nsteps = int(round(T_END / dt)); dt = T_END / nsteps + for _ in range(nsteps): + adv.solve(timestep=dt) + return float(sol.error(sol.at(T_END), T, norm="integral")), float(np.asarray(T.data).max()) + + +def test_forward_from_nodes_on_the_disc(): + uw.reset_default_model() + err, peak = _run(_disc(24), ("Upper",)) + assert abs(err - 0.01996) < 0.002, err # BASELINE (2026-09-26) + assert abs(peak - EXACT_PEAK) < 0.002, (peak, EXACT_PEAK) + + +def test_forward_from_nodes_holds_the_box(): + uw.reset_default_model() + mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(-1.0, -1.0), maxCoords=(1.0, 1.0), + cellSize=2.0 / 24, qdegree=3, regular=False) + err, peak = _run(mesh, ("Left", "Right", "Top", "Bottom")) + assert abs(err - 0.0617) < 0.006, err # BASELINE (2026-09-26) + assert abs(peak - EXACT_PEAK) < 0.005, (peak, EXACT_PEAK) From c434d8ac293daed57baacac03e71c1dcc1ac3092 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sat, 26 Sep 2026 19:35:25 -0700 Subject: [PATCH 02/21] Semi-Lagrangian histories named by trace and launch; one entry point; forward from nodes as an advection-diffusion option ddt.SemiLagrangian(mesh, psi_fn, V_fn, vtype, trace=, launch=) selects one of four schemes: BackwardNodesSemiLagrangian (was SemiLagrangian), BackwardIntegrationPointsSemiLagrangian (was IntegrationPointSemiLagrangian), ForwardIntegrationPointsSemiLagrangian (was ForwardSemiLagrangian) and ForwardNodesSemiLagrangian. A keyword the chosen scheme does not take is a TypeError. The old class names resolve with a FutureWarning. stress_transport takes "backward_nodes" (default), "backward_integration_points", "forward_integration_points", "lagrangian", "eulerian"; the old strings map with a FutureWarning. AdvDiffusionSLCN(transport=...) chooses the value history among the four semi-Lagrangian schemes. ForwardNodesSemiLagrangian carries a field known at its nodes and refuses a flux: a stress is formed at the integration points. A cell nothing reached now keeps the field it launched (no fit carried between steps); inflow is detected by the back-reflected point leaving the domain; a moved mesh is refused. ForwardIntegrationPointsSemiLagrangian takes degree and refuses anything but 1. BackwardNodesSemiLagrangian's continuous now defaults to True. Underworld development team with AI support from Claude Code Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo --- docs/api/systems_ddt.md | 34 ++- .../subsystems/integration-point-variables.md | 8 +- docs/developer/subsystems/stress-transport.md | 17 +- src/underworld3/constitutive_models.py | 8 +- .../cython/petsc_generic_snes_solvers.pyx | 16 +- src/underworld3/systems/__init__.py | 7 +- src/underworld3/systems/ddt.py | 219 ++++++++++++------ src/underworld3/systems/solver_template.py | 6 +- src/underworld3/systems/solvers.py | 213 +++++++++++------ .../test_1062_forward_stress_history_mpi.py | 4 +- tests/test_0066_integration_point_slcn.py | 34 +-- tests/test_1056_units_slcn_traceback.py | 2 +- tests/test_1059_stress_transport.py | 87 +++++-- tests/test_1060_stress_store_smoothing.py | 10 +- tests/test_1061_stress_forward_history.py | 4 +- tests/test_1063_stress_history_restart.py | 2 +- tests/test_1064_stress_history_units.py | 8 +- tests/test_1065_log_conformation.py | 10 +- ...st_1102_forward_nodes_rotating_gaussian.py | 6 +- 19 files changed, 467 insertions(+), 228 deletions(-) diff --git a/docs/api/systems_ddt.md b/docs/api/systems_ddt.md index d9ded7dfc..e3086044e 100644 --- a/docs/api/systems_ddt.md +++ b/docs/api/systems_ddt.md @@ -37,7 +37,37 @@ bypasses the order ramp, so the first solve runs at full BDF order. ### SemiLagrangian ```{eval-rst} -.. autoclass:: underworld3.systems.ddt.SemiLagrangian +.. autofunction:: underworld3.systems.ddt.SemiLagrangian +``` + +### BackwardNodesSemiLagrangian + +```{eval-rst} +.. autoclass:: underworld3.systems.ddt.BackwardNodesSemiLagrangian + :members: + :show-inheritance: +``` + +### BackwardIntegrationPointsSemiLagrangian + +```{eval-rst} +.. autoclass:: underworld3.systems.ddt.BackwardIntegrationPointsSemiLagrangian + :members: + :show-inheritance: +``` + +### ForwardIntegrationPointsSemiLagrangian + +```{eval-rst} +.. autoclass:: underworld3.systems.ddt.ForwardIntegrationPointsSemiLagrangian + :members: + :show-inheritance: +``` + +### ForwardNodesSemiLagrangian + +```{eval-rst} +.. autoclass:: underworld3.systems.ddt.ForwardNodesSemiLagrangian :members: :show-inheritance: ``` @@ -63,6 +93,6 @@ bypasses the order ramp, so the first solve runs at full BDF order. The following aliases are available via ``underworld3.systems``: - ``Lagrangian_DDt`` → {class}`~underworld3.systems.ddt.Lagrangian` -- ``SemiLagragian_DDt`` → {class}`~underworld3.systems.ddt.SemiLagrangian` +- ``SemiLagragian_DDt`` → {class}`~underworld3.systems.ddt.BackwardNodesSemiLagrangian` - ``Lagrangian_Swarm_DDt`` → {class}`~underworld3.systems.ddt.Lagrangian_Swarm` - ``Eulerian_DDt`` → {class}`~underworld3.systems.ddt.Eulerian` diff --git a/docs/developer/subsystems/integration-point-variables.md b/docs/developer/subsystems/integration-point-variables.md index a8e106e8e..1a78882ea 100644 --- a/docs/developer/subsystems/integration-point-variables.md +++ b/docs/developer/subsystems/integration-point-variables.md @@ -145,7 +145,7 @@ reading it, `evaluate`, the guards). ## Semi-Lagrangian history on the integration points -`uw.systems.ddt.IntegrationPointSemiLagrangian` is the SLCN history built on +`uw.systems.ddt.BackwardIntegrationPointsSemiLagrangian` is the SLCN history built on this variable. Its slots `psi_star[k]` are integration-point variables, so the value the weak form sees at each integration point is the solution from `k+1` steps ago evaluated exactly at the departure point of that @@ -158,7 +158,7 @@ evaluating the snapshot from time `n-k` at the foot. Every slot carries one evaluation error rather than one per generation. ```python -DuDt = uw.systems.ddt.IntegrationPointSemiLagrangian(mesh, T, V_fn, degree=2, order=1) +DuDt = uw.systems.ddt.BackwardIntegrationPointsSemiLagrangian(mesh, T, V_fn, degree=2, order=1) adv = uw.systems.AdvDiffusionSLCN(mesh, u_Field=T, V_fn=V_fn, DuDt=DuDt, order=1) ``` @@ -182,11 +182,11 @@ component per integration point: ```python # a momentum history for Navier-Stokes -DuDt = uw.systems.ddt.IntegrationPointSemiLagrangian( +DuDt = uw.systems.ddt.BackwardIntegrationPointsSemiLagrangian( mesh, v, v.sym, vtype=uw.VarType.VECTOR, degree=2, order=2) # a viscoelastic stress history -DFDt = uw.systems.ddt.IntegrationPointSemiLagrangian( +DFDt = uw.systems.ddt.BackwardIntegrationPointsSemiLagrangian( mesh, stress, v.sym, vtype=uw.VarType.SYM_TENSOR, degree=2, order=1) DFDt.psi_star[0].sym # a 2x2 symbolic matrix diff --git a/docs/developer/subsystems/stress-transport.md b/docs/developer/subsystems/stress-transport.md index a1def6f95..f5652da83 100644 --- a/docs/developer/subsystems/stress-transport.md +++ b/docs/developer/subsystems/stress-transport.md @@ -8,7 +8,7 @@ what limits it, and how to keep a run inside those limits. ```python stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) -stokes.stress_transport = "integration_point" # or "semi_lagrangian" (the default), "forward", "eulerian" +stokes.stress_transport = "forward_integration_points" # see the table for the others stokes.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( stokes.Unknowns, order=1, integrator="bdf", objective_rate="upper_convected") stokes.constitutive_model.Parameters.shear_viscosity_0 = eta_p @@ -19,11 +19,20 @@ stokes.constitutive_model.Parameters.dt_elastic = dt ## The five histories +The four semi-Lagrangian names are `_`, the arguments of +`uw.systems.ddt.SemiLagrangian(..., trace=, launch=)`: a backward trace follows the +characteristic back from each storage point and samples the old stress at the foot; a +forward trace carries the old stress from where it is known and fits the arrivals in +each cell. The fourth combination, forward from nodes, carries a field known at its +nodes (a temperature, say); a stress is formed at the integration points, so it is not +a stress history. The former names `semi_lagrangian`, `integration_point` and `forward` +are accepted with a warning. + | `stress_transport` | storage | carried by | stable at | fails by | |---|---|---|---|---| -| `semi_lagrangian` (nodal) | continuous P1 at the vertices | vertex trace-back, interpolation at the foot | any Courant number | excess stress in the first cells off a no-slip wall; on the confined cylinder that excess loses the conformation and the solve hangs | -| `integration_point` | continuous P1 store, sampled at the quadrature points | trace-back of every quadrature point | Courant near one, or below one with store smoothing | a cell-scale mode of the stress that grows below Courant one when the solvent viscosity is small | -| `forward` | discontinuous P1 per cell, fitted from the arrivals | fixed launch set of interior points (the integration points), one forward trajectory a step; the flux is read back at the launch points through a continuous P1 projection; an inflow cell's uncovered share is filled with the inflow value | the cylinder walls at dt 0.04; below Courant one with `flux_smoothing` at c = 0.023 (Waters-King 1/16, dt 0.0125: 0.9543 at t 1 and 0.5185 at t 6.5, against nodal 0.9622 and 0.5171) | the same cell-scale mode as the integration-point history without that smoothing (diverges at t 2.4 there); first order only; does not cross a periodic seam or follow a moving mesh | +| `backward_nodes` (the default) | continuous P1 at the vertices | vertex trace-back, interpolation at the foot | any Courant number | excess stress in the first cells off a no-slip wall; on the confined cylinder that excess loses the conformation and the solve hangs | +| `backward_integration_points` | continuous P1 store, sampled at the quadrature points | trace-back of every quadrature point | Courant near one, or below one with store smoothing | a cell-scale mode of the stress that grows below Courant one when the solvent viscosity is small | +| `forward_integration_points` | discontinuous P1 per cell, fitted from the arrivals | fixed launch set of interior points (the integration points), one forward trajectory a step; the flux is read back at the launch points through a continuous P1 projection; an inflow cell's uncovered share is filled with the inflow value | the cylinder walls at dt 0.04; below Courant one with `flux_smoothing` at c = 0.023 (Waters-King 1/16, dt 0.0125: 0.9543 at t 1 and 0.5185 at t 6.5, against nodal 0.9622 and 0.5171) | the same cell-scale mode as the integration-point history without that smoothing (diverges at t 2.4 there); first order only; does not cross a periodic seam or follow a moving mesh | | `lagrangian` (particles) | a swarm the solver owns and advects, one value per particle, read through a discontinuous cells proxy | the material points themselves: the constitutive flux is evaluated at the particles each step and never projected back to the mesh; a particle that entered through an inflow takes the inflow value | any Courant number; no numerical diffusion of the history | the cost and bookkeeping of a swarm, and a proxy that needs its cells kept populated (population control refills them); the conformation check does not read a per-point tensor from it | | `eulerian` (SUPG grid) | continuous P1 | assembled transport equation with streamline upwinding | with DEVSS | without DEVSS the velocity block loses its preconditioner as the stress grows | diff --git a/src/underworld3/constitutive_models.py b/src/underworld3/constitutive_models.py index a6bb2a521..6b65eaefa 100644 --- a/src/underworld3/constitutive_models.py +++ b/src/underworld3/constitutive_models.py @@ -47,7 +47,7 @@ from underworld3.utilities._api_tools import uw_object from underworld3.swarm import IndexSwarmVariable from underworld3.discretisation import MeshVariable -from underworld3.systems.ddt import SemiLagrangian as SemiLagrangian_DDt +from underworld3.systems.ddt import BackwardNodesSemiLagrangian from underworld3.systems.ddt import _bdf_coefficients, _as_float from underworld3.function.quantities import UWQuantity from underworld3.systems.ddt import Lagrangian as Lagrangian_DDt @@ -459,7 +459,7 @@ def DuDt(self): Returns ------- - SemiLagrangian_DDt or Lagrangian_DDt or None + BackwardNodesSemiLagrangian or Lagrangian_DDt or None The material derivative operator, or None if not set. """ return self._DuDt @@ -467,7 +467,7 @@ def DuDt(self): @DuDt.setter def DuDt( self, - DuDt_value: Union[SemiLagrangian_DDt, Lagrangian_DDt], + DuDt_value: Union[BackwardNodesSemiLagrangian, Lagrangian_DDt], ): """Set the material derivative operator for the unknown.""" self._DuDt = DuDt_value @@ -483,7 +483,7 @@ def DFDt(self): @DFDt.setter def DFDt( self, - DFDt_value: Union[SemiLagrangian_DDt, Lagrangian_DDt], + DFDt_value: Union[BackwardNodesSemiLagrangian, Lagrangian_DDt], ): """Set the material derivative operator for flux history.""" self._DFDt = DFDt_value diff --git a/src/underworld3/cython/petsc_generic_snes_solvers.pyx b/src/underworld3/cython/petsc_generic_snes_solvers.pyx index c7d53b68c..aed254a3b 100644 --- a/src/underworld3/cython/petsc_generic_snes_solvers.pyx +++ b/src/underworld3/cython/petsc_generic_snes_solvers.pyx @@ -3352,8 +3352,8 @@ class SNES_Scalar(SolverBaseClass): u_Field : uw.discretisation.MeshVariable = None, degree: int = 2, verbose = False, - DuDt : Union[uw.systems.ddt.SemiLagrangian, uw.systems.ddt.Lagrangian] = None, - DFDt : Union[uw.systems.ddt.SemiLagrangian, uw.systems.ddt.Lagrangian] = None, + DuDt : Union[uw.systems.ddt.BackwardNodesSemiLagrangian, uw.systems.ddt.Lagrangian] = None, + DFDt : Union[uw.systems.ddt.BackwardNodesSemiLagrangian, uw.systems.ddt.Lagrangian] = None, ): super().__init__(mesh) @@ -4265,8 +4265,8 @@ class SNES_Vector(SolverBaseClass): u_Field : uw.discretisation.MeshVariable = None, degree = 2, verbose = False, - DuDt : Union[uw.systems.ddt.SemiLagrangian, uw.systems.ddt.Lagrangian] = None, - DFDt : Union[uw.systems.ddt.SemiLagrangian, uw.systems.ddt.Lagrangian] = None, + DuDt : Union[uw.systems.ddt.BackwardNodesSemiLagrangian, uw.systems.ddt.Lagrangian] = None, + DFDt : Union[uw.systems.ddt.BackwardNodesSemiLagrangian, uw.systems.ddt.Lagrangian] = None, ): @@ -5268,8 +5268,8 @@ class SNES_MultiComponent(SolverBaseClass): n_components : int = None, degree = 2, verbose = False, - DuDt : Union[uw.systems.ddt.SemiLagrangian, uw.systems.ddt.Lagrangian] = None, - DFDt : Union[uw.systems.ddt.SemiLagrangian, uw.systems.ddt.Lagrangian] = None, + DuDt : Union[uw.systems.ddt.BackwardNodesSemiLagrangian, uw.systems.ddt.Lagrangian] = None, + DFDt : Union[uw.systems.ddt.BackwardNodesSemiLagrangian, uw.systems.ddt.Lagrangian] = None, ): super().__init__(mesh) @@ -6026,8 +6026,8 @@ class SNES_Stokes_SaddlePt(SolverBaseClass): degree : Optional[int] = 2, p_continuous : Optional[bool] = True, verbose : Optional[bool] =False, - DuDt : Union[uw.systems.ddt.SemiLagrangian, uw.systems.ddt.Lagrangian] = None, - DFDt : Union[uw.systems.ddt.SemiLagrangian, uw.systems.ddt.Lagrangian] = None, + DuDt : Union[uw.systems.ddt.BackwardNodesSemiLagrangian, uw.systems.ddt.Lagrangian] = None, + DFDt : Union[uw.systems.ddt.BackwardNodesSemiLagrangian, uw.systems.ddt.Lagrangian] = None, ): diff --git a/src/underworld3/systems/__init__.py b/src/underworld3/systems/__init__.py index 5ba37b93a..300aea382 100644 --- a/src/underworld3/systems/__init__.py +++ b/src/underworld3/systems/__init__.py @@ -93,9 +93,10 @@ # are the Lagrangian implementations actually distinct in reality ? from .ddt import Lagrangian as Lagrangian_DDt -from .ddt import SemiLagrangian as SemiLagragian_DDt -from .ddt import IntegrationPointSemiLagrangian as IntegrationPointSemiLagrangian_DDt -from .ddt import ForwardSemiLagrangian as ForwardSemiLagrangian_DDt +# former names of three of the four schemes ddt.SemiLagrangian selects +from .ddt import BackwardNodesSemiLagrangian as SemiLagragian_DDt +from .ddt import BackwardIntegrationPointsSemiLagrangian as IntegrationPointSemiLagrangian_DDt +from .ddt import ForwardIntegrationPointsSemiLagrangian as ForwardSemiLagrangian_DDt from .ddt import Lagrangian_Swarm as Lagrangian_Swarm_DDt from .ddt import Eulerian as Eulerian_DDt from .ddt import EulerianSUPG as EulerianSUPG_DDt diff --git a/src/underworld3/systems/ddt.py b/src/underworld3/systems/ddt.py index 148da5f3b..96d942ba2 100644 --- a/src/underworld3/systems/ddt.py +++ b/src/underworld3/systems/ddt.py @@ -50,6 +50,7 @@ underworld3.systems.solvers : PDE solvers using these time derivatives. """ +import inspect import math import warnings @@ -132,7 +133,7 @@ class DDtEulerianState(_DDtCoreState): @dataclass class DDtSemiLagrangianState(_DDtCoreState): - """Snapshot of a :class:`SemiLagrangian` DDt instance. + """Snapshot of a :class:`BackwardNodesSemiLagrangian` DDt instance. Like :class:`DDtEulerianState`, plus an optional ``forcing_star`` variable (when ``with_forcing_history=True``) used by ETD-2 @@ -146,7 +147,7 @@ class DDtSemiLagrangianState(_DDtCoreState): @dataclass class DDtIntegrationPointState(_DDtCoreState): - """Snapshot of an :class:`IntegrationPointSemiLagrangian` instance: the + """Snapshot of an :class:`BackwardIntegrationPointsSemiLagrangian` instance: the point-value slots and their nodal snapshots are mesh variables captured by name; this carries the bookkeeping.""" psi_star_var_names: list[str] = field(default_factory=list) @@ -157,7 +158,7 @@ class DDtIntegrationPointState(_DDtCoreState): @dataclass class DDtForwardState(_DDtCoreState): - """Snapshot of a :class:`ForwardSemiLagrangian` instance: the fitted + """Snapshot of a :class:`ForwardIntegrationPointsSemiLagrangian` instance: the fitted field and the launch values are mesh variables captured by name.""" psi_star_var_names: list[str] = field(default_factory=list) launch_var_name: str = "" @@ -569,7 +570,7 @@ class _DDtBase(uw_object): r"""Shared machinery for the DDt history-manager flavors. The five flavors (:class:`Symbolic`, :class:`Eulerian`, - :class:`SemiLagrangian`, :class:`Lagrangian`, + :class:`BackwardNodesSemiLagrangian`, :class:`Lagrangian`, :class:`Lagrangian_Swarm`) share the same BDF/Adams-Moulton coefficient bookkeeping, effective-order startup ramp, fixed-structure ``bdf()`` / ``adams_moulton_flux()`` expressions, @@ -989,7 +990,7 @@ def _refresh_source_snapshot(self): commits_flux_in_post_solve = False #: The forcing (strain-rate) history the second-order exponential - #: integrator reads; only :class:`SemiLagrangian` allocates one, on + #: integrator reads; only :class:`BackwardNodesSemiLagrangian` allocates one, on #: request. ``None`` means the integrator runs at first order (#739). forcing_star = None @@ -1073,8 +1074,8 @@ def inflow_value(self): the relaxed stress of the incoming flow. :class:`EulerianSUPG` compiles the value into a boundary term of its - transport solve; :class:`IntegrationPointSemiLagrangian` gives it to a - departure point restored to the boundary; :class:`ForwardSemiLagrangian` + transport solve; :class:`BackwardIntegrationPointsSemiLagrangian` gives it to a + departure point restored to the boundary; :class:`ForwardIntegrationPointsSemiLagrangian` fills the uncovered share of an inflow cell with it; :class:`Lagrangian` gives it to every particle that entered through an inflow: one whose back-trace over the step, or over one cell for a @@ -1102,7 +1103,7 @@ def inflow_value(self, value): "restores an out-of-bounds departure point to the boundary " "and reads the transported field there, which constrains " "the inflow but is not the value you set. EulerianSUPG, " - "IntegrationPointSemiLagrangian, ForwardSemiLagrangian and " + "BackwardIntegrationPointsSemiLagrangian, ForwardIntegrationPointsSemiLagrangian and " "Lagrangian apply it (#733, #783).", stacklevel=2) self._inflow_value = value @@ -1917,7 +1918,7 @@ class EulerianSUPG(Eulerian): component-wise to a scalar, a vector or a tensor unknown, and the streamline-upwind Petrov-Galerkin flux :math:`\tau\,R\otimes\mathbf{a}` of the solver's strong residual :math:`R`. The same solver takes a - :class:`SemiLagrangian` history in its place: that flavour answers zero + :class:`BackwardNodesSemiLagrangian` history in its place: that flavour answers zero for the advection and the flux because its history is already traced back along the characteristics. @@ -2559,12 +2560,12 @@ def _matrix_of(V): # TODO(BUG): a non-symmetric psi_fn under vtype=SYM_TENSOR is silently # reduced here, and this class keeps the LOWER entry where -# IntegrationPointSemiLagrangian keeps the UPPER one. Measured 2026-09-10 on +# BackwardIntegrationPointsSemiLagrangian keeps the UPPER one. Measured 2026-09-10 on # [[1+x, 2+y], [100.0, 3+x*y]]: nodal psi_star -> [[1.45, 100.0], [100.0, 3.21]], # integration-point -> [[1.46, 2.47], [2.47, 3.21]]. Neither averages and # neither warns. The integration-point path now warns; this one should too, # and the two should agree on which triangle wins. -class SemiLagrangian(_DDtBase): +class BackwardNodesSemiLagrangian(_DDtBase): r""" Semi-Lagrangian history manager. @@ -2604,7 +2605,7 @@ class SemiLagrangian(_DDtBase): sampling discretisation (the former ``swarm_degree`` / ``swarm_continuous`` were never read, issue #704). Denser sampling at the integration points is a separate history manager - (``IntegrationPointSemiLagrangian``, PR #703). + (``BackwardIntegrationPointsSemiLagrangian``, PR #703). varsymbol : str, optional LaTeX symbol for display. verbose : bool, default=False @@ -2711,7 +2712,7 @@ def __init__( V_fn: sympy.Function, vtype: uw.VarType, degree: int, - continuous: bool, + continuous: bool = True, varsymbol: Optional[str] = None, verbose: Optional[bool] = False, bcs=[], @@ -4734,7 +4735,7 @@ def _storage_components(vtype, shape): return [(i, j) for i in range(shape[0]) for j in range(shape[1])] -class IntegrationPointSemiLagrangian(_DDtBase): +class BackwardIntegrationPointsSemiLagrangian(_DDtBase): r"""Semi-Lagrangian history stored at the mesh integration points. The history slots ``psi_star[k]`` are @@ -4743,12 +4744,12 @@ class IntegrationPointSemiLagrangian(_DDtBase): solution from ``k+1`` steps ago evaluated **exactly** at the departure point of that integration point. There is no nodal history field and no second interpolation: only the FE solution's own error remains in the - advected term. Compare :class:`SemiLagrangian`, which samples at the + advected term. Compare :class:`BackwardNodesSemiLagrangian`, which samples at the nodes, stores a nodal ``psi_star`` and lets the assembler interpolate it to the integration points. Because a delta field cannot be sampled off its points, the chain - ``psi_star[k] <- psi_star[k-1]`` of :class:`SemiLagrangian` is replaced + ``psi_star[k] <- psi_star[k-1]`` of :class:`BackwardNodesSemiLagrangian` is replaced by nodal **snapshots** of the solution and of the velocity at the last ``order`` times. Slot ``k`` is filled by tracing ``k+1`` segments back from every integration point (segment ``j`` with the velocity at time @@ -4766,13 +4767,13 @@ class IntegrationPointSemiLagrangian(_DDtBase): What is not here (yet): units-aware velocity reduction, ALE / old-frame trace-back. Use - :class:`SemiLagrangian` for those, or :class:`Lagrangian_Swarm` when the + :class:`BackwardNodesSemiLagrangian` for those, or :class:`Lagrangian_Swarm` when the history should ride on particles rather than on the rule. Parameters ---------- mesh, psi_fn, V_fn, degree, continuous, varsymbol, verbose, bcs, order, theta - As for :class:`SemiLagrangian`. ``psi_fn`` may be a ``MeshVariable`` + As for :class:`BackwardNodesSemiLagrangian`. ``psi_fn`` may be a ``MeshVariable`` (its nodal data is then copied into the snapshot rather than re-evaluated) or an expression, of any ``vtype``. ``V_fn`` may be any expression (``-v``, ``v/2``, ``c(t) v``); the @@ -4862,7 +4863,7 @@ def __init__( self.store_smoothing = store_smoothing self.mesh = mesh if bcs: - raise ValueError("IntegrationPointSemiLagrangian applies no boundary conditions to its " + raise ValueError("BackwardIntegrationPointsSemiLagrangian applies no boundary conditions to its " "store; an inflow is set through inflow_value") self.bcs = [] self.verbose = verbose @@ -4923,7 +4924,7 @@ def __init__( ) if len(self._components) != self.num_components: raise RuntimeError( - f"IntegrationPointSemiLagrangian: {vtype} maps " + f"BackwardIntegrationPointsSemiLagrangian: {vtype} maps " f"{len(self._components)} components onto " f"{self.num_components} stored columns" ) @@ -5070,7 +5071,7 @@ def _check_rule_oversampling(self, degree): need = self._qdegree_with_at_least(local_dofs + 1) want = self._qdegree_with_at_least(2 * local_dofs) raise RuntimeError( - f"IntegrationPointSemiLagrangian: the mesh rule has {Nq} points per cell " + f"BackwardIntegrationPointsSemiLagrangian: the mesh rule has {Nq} points per cell " f"but a degree-{degree} history has {local_dofs} local dofs; the " "least-squares fit is not oversampled and is unstable at small Courant " f"number. For this cell type and history degree the rule needs at least " @@ -5079,7 +5080,7 @@ def _check_rule_oversampling(self, degree): ) if Nq < 2 * local_dofs: warnings.warn( - f"IntegrationPointSemiLagrangian: {Nq} rule points per cell for " + f"BackwardIntegrationPointsSemiLagrangian: {Nq} rule points per cell for " f"{local_dofs} local dofs is under 2x oversampling: weakly unstable under pure " "advection (growth ~1.005/step at 1.5x, Courant 0.25) and stable with physical " "diffusion at cell Peclet <= 100. 2x (qdegree 3 for P2 on triangles) is neutral.", @@ -5121,7 +5122,7 @@ def _check_psi_shape(self, psi_fn): expected = _psi_shape_for(self.vtype, self.mesh.cdim) if expected is not None and tuple(psi_fn.shape) != expected: raise ValueError( - f"IntegrationPointSemiLagrangian: psi_fn has shape " + f"BackwardIntegrationPointsSemiLagrangian: psi_fn has shape " f"{tuple(psi_fn.shape)} but vtype={self.vtype} on a cdim=" f"{self.mesh.cdim} mesh needs {expected}. Pass the vtype that " "matches the field, or reshape psi_fn." @@ -5137,7 +5138,7 @@ def _check_psi_shape(self, psi_fn): if supplied_var is not None and expected is not None: if int(supplied_var.num_components) != wanted: raise ValueError( - f"IntegrationPointSemiLagrangian: psi_fn stores " + f"BackwardIntegrationPointsSemiLagrangian: psi_fn stores " f"{supplied_var.num_components} components but vtype=" f"{self.vtype} stores {wanted}. A full tensor and a " "symmetric tensor share a shape; pass the vtype the field " @@ -5146,7 +5147,7 @@ def _check_psi_shape(self, psi_fn): components = getattr(self, "num_components", None) if components is not None and expected is not None and wanted != components: raise ValueError( - f"IntegrationPointSemiLagrangian: psi_fn needs {wanted} stored " + f"BackwardIntegrationPointsSemiLagrangian: psi_fn needs {wanted} stored " f"components but this history has {components}; vtype=" f"{self.vtype} is probably not the vtype of the field." ) @@ -5164,7 +5165,7 @@ def _check_psi_shape(self, psi_fn): import warnings warnings.warn( - f"IntegrationPointSemiLagrangian: psi_fn is not symmetric at " + f"BackwardIntegrationPointsSemiLagrangian: psi_fn is not symmetric at " f"{dropped} but vtype=SYM_TENSOR stores only the upper " "triangle, so the lower entries are discarded (not averaged). " "Symmetrise psi_fn explicitly, or use VarType.TENSOR.", @@ -5182,7 +5183,7 @@ def _object_viewer(self): def _nudged_node_coords(self, var): """ND node coordinates of ``var`` moved 0.1 % toward their cell centroids so boundary nodes locate unambiguously (see - :meth:`SemiLagrangian._centroid_shifted_node_coords`).""" + :meth:`BackwardNodesSemiLagrangian._centroid_shifted_node_coords`).""" coords = np.asarray(var.coords_nd) cellid = self.mesh.get_closest_cells(coords).reshape(-1) cent = np.asarray(self.mesh._centroids)[cellid] @@ -5351,7 +5352,7 @@ def update_post_solve(self, dt, evalf=False, verbose=False, **_ignored): self._n_solves_completed += 1 -class ForwardSemiLagrangian(_DDtBase): +class ForwardIntegrationPointsSemiLagrangian(_DDtBase): r"""Semi-Lagrangian history carried forward from a fixed set of launch points inside the cells, read by the weak form through a per-cell fit. @@ -5403,6 +5404,7 @@ def __init__( psi_fn, V_fn, vtype=VarType.SCALAR, + degree: int = 1, varsymbol: Optional[str] = None, order: int = 1, theta: float = 0.5, @@ -5411,11 +5413,14 @@ def __init__( ): super().__init__() if order != 1: - raise NotImplementedError("ForwardSemiLagrangian carries one level; order must be 1") + raise NotImplementedError("ForwardIntegrationPointsSemiLagrangian carries one level; order must be 1") + if degree != 1: + raise NotImplementedError("ForwardIntegrationPointsSemiLagrangian fits a linear polynomial per " + f"cell; degree must be 1, not {degree}") if mesh.cdim != mesh.dim: - raise NotImplementedError("ForwardSemiLagrangian fits in the embedding coordinates; no manifolds") + raise NotImplementedError("ForwardIntegrationPointsSemiLagrangian fits in the embedding coordinates; no manifolds") if _unsupported: - warnings.warn(f"ForwardSemiLagrangian ignores {sorted(_unsupported)}: it has one level, " + warnings.warn(f"ForwardIntegrationPointsSemiLagrangian ignores {sorted(_unsupported)}: it has one level, " "a linear fit per cell and no smoothing or monotone option", stacklevel=2) self.vtype = vtype self.mesh = mesh @@ -5427,7 +5432,7 @@ def __init__( self._psi_fn = psi_fn if isinstance(psi_fn, sympy.Matrix) else sympy.Matrix([[psi_fn]]) expected = _psi_shape_for(vtype, mesh.cdim) if expected is not None and tuple(self._psi_fn.shape) != expected: - raise ValueError(f"ForwardSemiLagrangian: psi_fn has shape {tuple(self._psi_fn.shape)} " + raise ValueError(f"ForwardIntegrationPointsSemiLagrangian: psi_fn has shape {tuple(self._psi_fn.shape)} " f"but vtype={vtype} on a cdim={mesh.cdim} mesh needs {expected}") self._init_history_tracking(1) if varsymbol is None: @@ -5452,7 +5457,7 @@ def __init__( self._launch = np.array(np.asarray(launch_var.coords_nd).reshape(-1, mesh.cdim)) self._nq = int(launch_var.num_points_per_cell) if self._nq < mesh.dim + 1: - raise ValueError(f"ForwardSemiLagrangian needs at least {mesh.dim + 1} integration points per " + raise ValueError(f"ForwardIntegrationPointsSemiLagrangian needs at least {mesh.dim + 1} integration points per " f"cell for a linear fit; this mesh's rule has {self._nq} (raise qdegree)") w_ref = np.asarray(mesh.integration_rule.getData()[1]).reshape(-1) self._cell_measure = self._cell_measures() @@ -5530,7 +5535,7 @@ def _boundary_faces(self): # the mesh's own boundary label tells them apart. label = dm.getLabel("All_Boundaries") if dm.hasLabel("All_Boundaries") else None if label is None and uw.mpi.size > 1: - raise RuntimeError("ForwardSemiLagrangian: the mesh has no All_Boundaries label, so " + raise RuntimeError("ForwardIntegrationPointsSemiLagrangian: the mesh has no All_Boundaries label, so " "partition faces cannot be told from domain faces in parallel") for f in range(f0, f1): if dm.getSupportSize(f) != 1: @@ -5573,7 +5578,7 @@ def psi_fn(self, new_fn): new_fn = new_fn if isinstance(new_fn, sympy.Matrix) else sympy.Matrix([[new_fn]]) expected = _psi_shape_for(self.vtype, self.mesh.cdim) if expected is not None and tuple(new_fn.shape) != expected: - raise ValueError(f"ForwardSemiLagrangian: psi_fn has shape {tuple(new_fn.shape)}, needs {expected}") + raise ValueError(f"ForwardIntegrationPointsSemiLagrangian: psi_fn has shape {tuple(new_fn.shape)}, needs {expected}") self._psi_fn = new_fn def _object_viewer(self): @@ -5729,7 +5734,7 @@ def update_pre_solve(self, dt, evalf=False, verbose=False, store_result=True, ** self._dt = dt = self._nondim_timestep(dt) if self._geometry_stamp() != self._launch_geometry: raise NotImplementedError( - "ForwardSemiLagrangian: the launch set, cell measures and boundary faces were " + "ForwardIntegrationPointsSemiLagrangian: the launch set, cell measures and boundary faces were " "built for the mesh as it was, and the mesh has moved or been re-meshed since; " "this flavour does not follow a changing mesh") if not self._history_initialised: @@ -5786,14 +5791,16 @@ class ForwardNodesSemiLagrangian(_DDtBase): reads that fit back at the nodes. The fit never reaches across an element boundary, where the interpolant has a kink. A cell with too few arrivals, or arrivals on a line, falls back to a linear fit over the nearest arrivals; a - cell nothing reached keeps its previous fit (see + cell nothing reached keeps the field it launched (see :class:`~underworld3.utilities.cell_polynomial_projection.CellPolynomialProjector`). Arrivals that leave the domain are dropped; a node the flow reached from outside takes :attr:`inflow_value` when one is set. - The integration-point counterpart is :class:`ForwardSemiLagrangian`, which - launches from the quadrature points, where a flux (a stress) is formed. - First order; serial. + The integration-point counterpart is :class:`ForwardIntegrationPointsSemiLagrangian`, which + launches from the quadrature points, where a flux (a stress) is formed, and + is the forward scheme for one: this class carries a field and refuses a flux. + First order; serial (a node near a partition seam needs arrivals from the + other rank); a fixed mesh. Parameters ---------- @@ -5850,7 +5857,6 @@ def __init__(self, mesh, psi_fn, V_fn, vtype=VarType.SCALAR, degree=1, varsymbol self._projector = CellPolynomialProjector(self._fit_var) self._interior = np.asarray(mesh._get_coords_for_basis(self.degree + 1, continuous=False) ).reshape(-1, mesh.cdim) - self._fit = None self._n_v = 2 self._init_coefficient_expressions(1, self.theta, with_exp=True) self._register_with_default_model() @@ -5878,7 +5884,7 @@ def state(self, s: "DDtForwardNodesState") -> None: # ------------------------------------------------------------------ def _nodes(self): - return np.asarray(_to_nondim_ndarray(self.psi_star[0].coords)).reshape(-1, self.mesh.cdim) + return np.asarray(self.psi_star[0].coords_nd).reshape(-1, self.mesh.cdim) def _values_at(self, expr, X): """``expr`` at the points ``X``, one column per stored component.""" @@ -5888,28 +5894,30 @@ def _values_at(self, expr, X): for (i, j) in self._components]) def _reconstruct(self, arrivals, values, nodes): - """Fit the arrivals in each cell at the field's degree; the fit at the nodes.""" + """Fit the arrivals in each cell at the field's degree; the fit at the nodes. + A cell nothing reached keeps the field it launched.""" inside = np.asarray(self.mesh.points_in_domain(arrivals), dtype=bool) - self._fit = self._projector.fit(arrivals[inside], values[inside], old=self._fit) - return np.nan_to_num(self._projector.interpolate(self._fit, nodes)) + launched = self._values_at(self._psi_fn, np.asarray(self._fit_var.coords_nd)) + fit = self._projector.fit(arrivals[inside], values[inside], old=launched) + at_nodes = self._projector.interpolate(fit, nodes) + if np.isnan(at_nodes).any(): + raise RuntimeError("ForwardNodesSemiLagrangian: a node lies in no cell of the fit") + return at_nodes # ------------------------------------------------------------------ def initialise_history(self): - """Start from the current field at the nodes. A history already placed - by :meth:`commit_flux_to_history` is the start, and is kept.""" + """Start from the current field at the nodes.""" self.characteristics.initialise_levels(self._n_v) - if not self._history_committed: - self.psi_star[0].data[:, :] = self._values_at(self._psi_fn, self._nodes()) + self.psi_star[0].data[:, :] = self._values_at(self._psi_fn, self._nodes()) self._history_initialised = True - def update_pre_solve(self, dt, evalf=False, verbose=False, store_result=True, **_ignored): - """Carry the field forward one step from the nodes and rebuild it there. - - ``store_result=False`` says the store already holds the values to launch - (placed by :meth:`commit_flux_to_history`); otherwise the tracked field - is read at the nodes first. - """ + def update_pre_solve(self, dt, evalf=False, verbose=False, **_ignored): + """Carry the field forward one step from the nodes and rebuild it there.""" self._dt = dt = self._nondim_timestep(dt) + if self._projector.mesh_version != self.mesh._mesh_version: + raise NotImplementedError( + "ForwardNodesSemiLagrangian: the launch lattice and the per-cell fit were built " + "for the mesh as it was, and the mesh has moved or been re-meshed since") if not self._history_initialised: self.initialise_history() _update_bdf_values(self._bdf_coeffs, self.effective_order, self._dt, self._dt_history) @@ -5919,11 +5927,7 @@ def update_pre_solve(self, dt, evalf=False, verbose=False, store_result=True, ** trace.begin_step(dt) nodes = self._nodes() launch = np.vstack([nodes, self._interior]) - # the tracked field, or (when a flux was committed) the store, which is - # a polynomial inside each element, so its interior values are exact - source = self._psi_fn if store_result else self.psi_star[0].sym - values = np.vstack([self._values_at(source, nodes) if store_result else np.array(self.psi_star[0].data), - self._values_at(source, self._interior)]) + values = self._values_at(self._psi_fn, launch) key = (_basis_key_of(self.psi_star[0]), "launch") arrivals = np.asarray(trace.departure_points(key, launch, (("first", 0, -float(dt)),), evalf=evalf, clamp_final=False)) @@ -5932,8 +5936,7 @@ def update_pre_solve(self, dt, evalf=False, verbose=False, store_result=True, ** if self._inflow_value is not None: # a node whose back-trace leaves the domain holds fluid that entered this step back = 2.0 * nodes - arrivals - restored = np.asarray(self.mesh.return_coords_to_bounds(back.copy())).reshape(back.shape) - entered = np.any(restored != back, axis=1) + entered = ~np.asarray(self.mesh.points_in_domain(back), dtype=bool) if entered.any(): self._write_inflow(self.psi_star[0], nodes, entered) if self._owns_characteristics: @@ -5949,6 +5952,90 @@ def update_post_solve(self, dt, evalf=False, verbose=False, **_ignored): self._n_solves_completed += 1 def commit_flux_to_history(self, flux, verbose=False): - """Read the new flux at the nodes and leave it in the store until the next carry.""" - self.psi_star[0].data[:, :] = self._values_at(flux, self._nodes()) - self._history_committed = True + """Refused: a flux is formed at the integration points, not known at the nodes.""" + raise NotImplementedError( + "ForwardNodesSemiLagrangian carries a field known at its nodes; a flux (a stress) " + "is formed at the integration points, so carry it with " + "ForwardIntegrationPointsSemiLagrangian") + + +_SEMI_LAGRANGIAN_SCHEMES = { + ("backward", "nodes"): BackwardNodesSemiLagrangian, + ("backward", "integration_points"): BackwardIntegrationPointsSemiLagrangian, + ("forward", "integration_points"): ForwardIntegrationPointsSemiLagrangian, + ("forward", "nodes"): ForwardNodesSemiLagrangian, +} + + +def SemiLagrangian(mesh, psi_fn, V_fn, vtype=VarType.SCALAR, *, trace="backward", launch="nodes", + **kwargs): + r"""Semi-Lagrangian history of ``psi_fn`` carried by ``V_fn``. + + A semi-Lagrangian history holds the carried quantity at the points where + the weak form reads it, one step along the characteristic. The schemes + differ in two choices: + + ============ ====================== ============================================== + ``trace`` ``launch`` scheme + ============ ====================== ============================================== + backward nodes :class:`BackwardNodesSemiLagrangian` + backward integration_points :class:`BackwardIntegrationPointsSemiLagrangian` + forward integration_points :class:`ForwardIntegrationPointsSemiLagrangian` + forward nodes :class:`ForwardNodesSemiLagrangian` + ============ ====================== ============================================== + + ``trace="backward"`` follows the characteristic back from each storage + point and samples the old field at the departure point. + ``trace="forward"`` launches the old field from where it is known, carries + it one step forward, and fits the arrivals in each cell. ``launch`` names + the storage points: the field's ``nodes``, or the mesh's + ``integration_points``, where a flux such as a stress is formed. + + The remaining arguments are keywords for the scheme; see its class for + them. A keyword the chosen scheme does not take is an error. + + Parameters + ---------- + mesh : Mesh + psi_fn : sympy expression or matrix + The carried quantity, for example ``T.sym``. + V_fn : sympy matrix + The velocity that carries it. + vtype : VarType + trace : {"backward", "forward"} + launch : {"nodes", "integration_points"} + + Examples + -------- + .. code-block:: python + + DuDt = uw.systems.ddt.SemiLagrangian( + mesh, T.sym, V.sym, uw.VarType.SCALAR, degree=T.degree, + trace="forward", launch="nodes") + """ + try: + scheme = _SEMI_LAGRANGIAN_SCHEMES[(trace, launch)] + except KeyError: + raise ValueError( + f"no semi-Lagrangian scheme traces {trace!r} from {launch!r}: trace is " + "'backward' or 'forward', launch is 'nodes' or 'integration_points'") from None + taken = inspect.signature(scheme).parameters + refused = sorted(k for k in kwargs if k not in taken or taken[k].kind is taken[k].VAR_KEYWORD) + if refused: + raise TypeError(f"{scheme.__name__} takes no {refused}") + return scheme(mesh, psi_fn, V_fn, vtype, **kwargs) + + +_RENAMED = { + "IntegrationPointSemiLagrangian": "BackwardIntegrationPointsSemiLagrangian", + "ForwardSemiLagrangian": "ForwardIntegrationPointsSemiLagrangian", +} + + +def __getattr__(name): + if name in _RENAMED: + warnings.warn( + f"ddt.{name} is now ddt.{_RENAMED[name]}, or " + f"ddt.SemiLagrangian(..., trace=..., launch=...)", FutureWarning, stacklevel=2) + return globals()[_RENAMED[name]] + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/underworld3/systems/solver_template.py b/src/underworld3/systems/solver_template.py index 873d298bb..5164e5d1f 100644 --- a/src/underworld3/systems/solver_template.py +++ b/src/underworld3/systems/solver_template.py @@ -9,7 +9,7 @@ from typing import Optional, Union, Callable import underworld3 as uw from underworld3.systems import SNES_Scalar, SNES_Vector, SNES_Stokes_SaddlePt -from underworld3.systems.ddt import SemiLagrangian, Lagrangian, Eulerian +from underworld3.systems.ddt import BackwardNodesSemiLagrangian, Lagrangian, Eulerian from underworld3 import timing from underworld3.systems.solvers import expression @@ -89,8 +89,8 @@ def __init__( u_Field: Optional[uw.discretisation.MeshVariable] = None, degree: int = 2, verbose: bool = False, - DuDt: Optional[Union[SemiLagrangian, Lagrangian, Eulerian]] = None, - DFDt: Optional[Union[SemiLagrangian, Lagrangian, Eulerian]] = None, + DuDt: Optional[Union[BackwardNodesSemiLagrangian, Lagrangian, Eulerian]] = None, + DFDt: Optional[Union[BackwardNodesSemiLagrangian, Lagrangian, Eulerian]] = None, ): """ Initialize the solver. diff --git a/src/underworld3/systems/solvers.py b/src/underworld3/systems/solvers.py index 1c6614f30..d8b806186 100644 --- a/src/underworld3/systems/solvers.py +++ b/src/underworld3/systems/solvers.py @@ -51,6 +51,8 @@ >>> stokes.solve() """ +import warnings + import sympy from sympy import sympify import numpy as np @@ -453,12 +455,29 @@ def _invalidate_solution_cache(u): target_var._canonical_data = None -from .ddt import SemiLagrangian as SemiLagrangian_DDt +from .ddt import BackwardNodesSemiLagrangian from .ddt import Lagrangian as Lagrangian_DDt from .ddt import Lagrangian_Swarm as Lagrangian_Swarm_DDt from .ddt import Eulerian as Eulerian_DDt from .ddt import Symbolic as Symbolic_DDt +# The semi-Lagrangian schemes a solver can build its history with, named +# "_" after the arguments of ddt.SemiLagrangian. A stress is +# formed at the integration points, so it is not carried forward from nodes. +_SEMI_LAGRANGIAN_TRANSPORTS = ( + "backward_nodes", "backward_integration_points", + "forward_integration_points", "forward_nodes", +) +_STRESS_TRANSPORTS = ( + "backward_nodes", "backward_integration_points", "forward_integration_points", + "lagrangian", "eulerian", +) +_RENAMED_TRANSPORTS = { + "semi_lagrangian": "backward_nodes", + "integration_point": "backward_integration_points", + "forward": "forward_integration_points", +} + class _ConstitutiveModelStateMixin: """Single definition of the constitutive-model readiness flag. @@ -504,9 +523,9 @@ class SNES_Poisson(_ConstitutiveModelStateMixin, SNES_Scalar): Polynomial degree for the solution field (default: 2). verbose : bool, optional Enable verbose output during solve. - DuDt : SemiLagrangian_DDt or Lagrangian_DDt, optional + DuDt : BackwardNodesSemiLagrangian or Lagrangian_DDt, optional Time derivative operator for time-dependent problems. - DFDt : SemiLagrangian_DDt or Lagrangian_DDt, optional + DFDt : BackwardNodesSemiLagrangian or Lagrangian_DDt, optional Time derivative operator for the flux. Notes @@ -527,8 +546,8 @@ def __init__( u_Field: uw.discretisation.MeshVariable = None, degree=2, verbose=False, - DuDt: Union[SemiLagrangian_DDt, Lagrangian_DDt] = None, - DFDt: Union[SemiLagrangian_DDt, Lagrangian_DDt] = None, + DuDt: Union[BackwardNodesSemiLagrangian, Lagrangian_DDt] = None, + DFDt: Union[BackwardNodesSemiLagrangian, Lagrangian_DDt] = None, ): if type(degree) is bool: # Legacy positional order (mesh, u_Field, verbose, degree): the @@ -1394,9 +1413,9 @@ class SNES_Stokes(_ConstitutiveModelStateMixin, SNES_Stokes_SaddlePt): If True (default), pressure is continuous. Set False for discontinuous pressure. verbose : bool, optional Enable verbose output during solving. Default is False. - DuDt : SemiLagrangian_DDt or Lagrangian_DDt, optional + DuDt : BackwardNodesSemiLagrangian or Lagrangian_DDt, optional Material derivative operator for velocity (used in derived classes). - DFDt : SemiLagrangian_DDt or Lagrangian_DDt, optional + DFDt : BackwardNodesSemiLagrangian or Lagrangian_DDt, optional Material derivative operator for flux (used in viscoelastic models). Notes @@ -1450,8 +1469,8 @@ def __init__( p_continuous: Optional[bool] = True, verbose: Optional[bool] = False, # Not used in Stokes, but may be used in NS, VE etc - DuDt: Union[SemiLagrangian_DDt, Lagrangian_DDt] = None, - DFDt: Union[SemiLagrangian_DDt, Lagrangian_DDt] = None, + DuDt: Union[BackwardNodesSemiLagrangian, Lagrangian_DDt] = None, + DFDt: Union[BackwardNodesSemiLagrangian, Lagrangian_DDt] = None, ): super().__init__( mesh, @@ -1544,43 +1563,56 @@ def set_jacobian_F1_source(self, F1_source, linesearch="cp"): @property def stress_transport(self) -> str: - """How a viscoelastic stress history is carried: ``"semi_lagrangian"`` - (default), ``"integration_point"``, ``"forward"``, ``"lagrangian"`` or - ``"eulerian"``. - - ``"forward"`` carries the stress from a fixed set of launch points inside - the cells (the integration points), one forward trajectory a step, and - fits the arrivals per cell; the constitutive flux is read at the launch - points through a continuous P1 projection. It holds the Maxwell - start-up below Courant one where the integration-point history rings - (see :class:`~underworld3.systems.ddt.ForwardSemiLagrangian`). - - The semi-Lagrangian history traces the stress back along characteristics - and stores it on a nodal field, which the assembler then interpolates to - the integration points: two interpolations a step. ``"integration_point"`` - traces back to the integration points themselves and holds the history - there, so it carries one evaluation error and needs no projection. The - Eulerian one transports the stress on the grid with the same + """How a viscoelastic stress history is carried: ``"backward_nodes"`` + (default), ``"backward_integration_points"``, + ``"forward_integration_points"``, ``"lagrangian"`` or ``"eulerian"``. + + The first three are semi-Lagrangian schemes of + :func:`~underworld3.systems.ddt.SemiLagrangian`, named by the direction + of the trace and the points the history is held at. A backward trace + follows the characteristic back from each storage point and samples the + old stress at the departure point. ``"backward_nodes"`` stores the + history on a nodal field, which the assembler then interpolates to the + integration points: two interpolations a step. + ``"backward_integration_points"`` traces back to the integration points + themselves and holds the history there: one evaluation error and no + projection. A forward trace launches the old stress from where it is + known, carries it one step forward and fits the arrivals in each cell. + ``"forward_integration_points"`` launches from the integration points, + where the stress is formed, and reads the constitutive flux there + through a continuous P1 projection; it holds the Maxwell start-up below + Courant one where the backward integration-point history rings (see + :class:`~underworld3.systems.ddt.ForwardIntegrationPointsSemiLagrangian`). + The fourth semi-Lagrangian scheme, forward from nodes, carries a field + known at its nodes; a stress is formed at the integration points, so it + is not offered here. + + ``"eulerian"`` transports the stress on the grid with the same streamline-upwind stabilisation the Eulerian solvers use, and gives the same answer on any partition. ``"lagrangian"`` carries the stress on a swarm of material points the solver creates and advects, reading the constitutive flux at the particles each step and never projecting it back to the mesh: no numerical diffusion of the history, at the cost of - the swarm (see :class:`~underworld3.systems.ddt.Lagrangian`). The default - is ``"semi_lagrangian"``. Set - it before the constitutive model is assigned: assigning the model - creates the history, and the choice cannot change after that. + the swarm (see :class:`~underworld3.systems.ddt.Lagrangian`). + + Set it before the constitutive model is assigned: assigning the model + creates the history, and the choice cannot change after that. The + former names ``"semi_lagrangian"``, ``"integration_point"`` and + ``"forward"`` are accepted, with a warning. """ - return getattr(self, "_stress_transport", "semi_lagrangian") + return getattr(self, "_stress_transport", "backward_nodes") @stress_transport.setter def stress_transport(self, value): value = str(value) - if value not in ("semi_lagrangian", "integration_point", "forward", - "lagrangian", "eulerian"): + if value in _RENAMED_TRANSPORTS: + warnings.warn( + f"stress_transport={value!r} is now {_RENAMED_TRANSPORTS[value]!r}", + FutureWarning, stacklevel=2) + value = _RENAMED_TRANSPORTS[value] + if value not in _STRESS_TRANSPORTS: raise ValueError( - "stress_transport must be 'semi_lagrangian', 'integration_point', " - f"'forward', 'lagrangian' or 'eulerian', not {value!r}.") + f"stress_transport must be one of {_STRESS_TRANSPORTS}, not {value!r}.") if self.Unknowns.DFDt is not None: raise RuntimeError( "the stress history already exists: set stress_transport before the " @@ -1790,51 +1822,51 @@ def _create_stress_history_ddt(self, order=2): # dimensionless log-conformation of one units=uw.units.Pa if getattr(cm, "_stress_history", "stress") == "stress" else None, ) - if self.stress_transport == "integration_point": + if self.stress_transport == "backward_integration_points": unsupported = set(ddt_kwargs) - {"with_forcing_history"} if unsupported: raise NotImplementedError( f"{type(cm).__name__} asks its stress history for " f"{sorted(unsupported)}, which the integration-point flavour " - "does not provide; use stress_transport='semi_lagrangian'.") - self.Unknowns.DFDt = uw.systems.ddt.IntegrationPointSemiLagrangian( + "does not provide; use stress_transport='backward_nodes'.") + self.Unknowns.DFDt = uw.systems.ddt.BackwardIntegrationPointsSemiLagrangian( self.mesh, sympy.Matrix.zeros(self.mesh.dim, self.mesh.dim), self.u.sym, **ddt_kwargs, **{k: v for k, v in common.items() if k != "smoothing"}, ) - elif self.stress_transport == "forward": + elif self.stress_transport == "forward_integration_points": if ddt_kwargs: raise NotImplementedError( f"{type(cm).__name__} asks its stress history for " f"{sorted(ddt_kwargs)}, which the forward flavour does not provide; " - "use stress_transport='semi_lagrangian' for it.") - self.Unknowns.DFDt = uw.systems.ddt.ForwardSemiLagrangian( + "use stress_transport='backward_nodes' for it.") + self.Unknowns.DFDt = uw.systems.ddt.ForwardIntegrationPointsSemiLagrangian( self.mesh, sympy.Matrix.zeros(self.mesh.dim, self.mesh.dim), self.u.sym, - vtype=common["vtype"], varsymbol=common["varsymbol"], order=order, - units=common["units"], + vtype=common["vtype"], degree=common["degree"], varsymbol=common["varsymbol"], + order=order, units=common["units"], ) elif self.stress_transport == "lagrangian": if ddt_kwargs: raise NotImplementedError( f"{type(cm).__name__} asks its stress history for " f"{sorted(ddt_kwargs)}, which the particle Lagrangian flavour does " - "not provide; use stress_transport='semi_lagrangian' for it.") + "not provide; use stress_transport='backward_nodes' for it.") # Order 1 BDF only for now: the particle flavour has no exponential # coefficients (it is built with_exp=False), and order 2 is not yet # validated. Refuse cleanly rather than crash inside the first solve. if getattr(cm, "_integrator", "bdf") != "bdf": raise NotImplementedError( "the particle Lagrangian stress history supports the BDF " - "integrator only; use stress_transport='semi_lagrangian' for the " + "integrator only; use stress_transport='backward_nodes' for the " "exponential one.") if order > 1: raise NotImplementedError( "the particle Lagrangian stress history is first order for now; " - "use stress_transport='semi_lagrangian' for order 2.") + "use stress_transport='backward_nodes' for order 2.") # The solver owns the swarm: Lagrangian creates and populates it, and # carries the stress on it. Lagrangian_Swarm (a user-supplied swarm) # stays available by passing DFDt= to the constructor. @@ -1856,7 +1888,7 @@ def _create_stress_history_ddt(self, order=2): raise NotImplementedError( f"{type(cm).__name__} asks its stress history for " f"{sorted(ddt_kwargs)}, which only the semi-Lagrangian flavour " - "provides; use stress_transport='semi_lagrangian' for it.") + "provides; use stress_transport='backward_nodes' for it.") self.Unknowns.DFDt = uw.systems.ddt.EulerianSUPG( self.mesh, sympy.Matrix.zeros(self.mesh.dim, self.mesh.dim), @@ -2807,8 +2839,8 @@ def __init__( order: Optional[int] = 2, p_continuous: Optional[bool] = True, verbose: Optional[bool] = False, - DuDt: Union[SemiLagrangian_DDt, Lagrangian_DDt] = None, - DFDt: Union[SemiLagrangian_DDt, Lagrangian_DDt] = None, + DuDt: Union[BackwardNodesSemiLagrangian, Lagrangian_DDt] = None, + DFDt: Union[BackwardNodesSemiLagrangian, Lagrangian_DDt] = None, ): import warnings warnings.warn( @@ -2935,8 +2967,8 @@ def __init__( degree: Optional[int] = 2, p_continuous: Optional[bool] = True, verbose: Optional[bool] = False, - DuDt: Union[SemiLagrangian_DDt, Lagrangian_DDt] = None, - DFDt: Union[SemiLagrangian_DDt, Lagrangian_DDt] = None, + DuDt: Union[BackwardNodesSemiLagrangian, Lagrangian_DDt] = None, + DFDt: Union[BackwardNodesSemiLagrangian, Lagrangian_DDt] = None, ): super().__init__( mesh, @@ -4382,13 +4414,13 @@ class SNES_AdvectionDiffusion(SNES_Scalar): Function to restore particles to valid domain. verbose : bool, default=False Enable verbose output. - DuDt : SemiLagrangian_DDt or Lagrangian_DDt, optional + DuDt : BackwardNodesSemiLagrangian or Lagrangian_DDt, optional Time derivative operator for the unknown. - DFDt : SemiLagrangian_DDt or Lagrangian_DDt, optional + DFDt : BackwardNodesSemiLagrangian or Lagrangian_DDt, optional Time derivative operator for the flux. monotone_mode : str or None, optional Monotonicity limiter for the semi-Lagrangian trace-back. - Forwarded to the internally-constructed ``SemiLagrangian_DDt`` + Forwarded to the internally-constructed ``BackwardNodesSemiLagrangian`` instances for ``DuDt`` and ``DFDt``. - ``None`` (default): pure FE trace-back. Can overshoot at @@ -4411,7 +4443,7 @@ class SNES_AdvectionDiffusion(SNES_Scalar): theta : float, default=0.5 Adams-Moulton theta for the diffusive flux at order 1. Forwarded to the internally-constructed - ``SemiLagrangian_DDt`` instances (same forwarding rule as + ``BackwardNodesSemiLagrangian`` instances (same forwarding rule as ``monotone_mode``). - ``0.5`` (default): Crank-Nicolson, A-stable but not @@ -4422,6 +4454,17 @@ class SNES_AdvectionDiffusion(SNES_Scalar): SLCN+CN ringing dominates the discretisation error. - ``0.0``: Forward Euler — unstable for stiff diffusion; included for completeness. + transport : str, default="backward_nodes" + The semi-Lagrangian scheme of the internally-constructed ``DuDt``: + ``"backward_nodes"``, ``"backward_integration_points"``, + ``"forward_integration_points"`` or ``"forward_nodes"``, named by the + ``trace`` and ``launch`` arguments of + :func:`~underworld3.systems.ddt.SemiLagrangian`; an option the scheme + does not take (``monotone_mode`` on a forward scheme, say) is refused. + Only the value history is chosen: the diffusive flux history ``DFDt`` + is always backward from the nodes. ``"forward_integration_points"`` + fits a linear polynomial per cell and so needs a degree-1 field; + ``"forward_nodes"`` runs in serial only. old_frame_traceback : bool, default=False Use the old-frame semi-Lagrangian reach-back for the advective ``DuDt`` history on a moving mesh (free surface or interior-node @@ -4482,11 +4525,12 @@ def __init__( order: int = 1, restore_points_func: Callable = None, verbose=False, - DuDt: Union[SemiLagrangian_DDt, Lagrangian_DDt] = None, - DFDt: Union[SemiLagrangian_DDt, Lagrangian_DDt] = None, + DuDt: Union[BackwardNodesSemiLagrangian, Lagrangian_DDt] = None, + DFDt: Union[BackwardNodesSemiLagrangian, Lagrangian_DDt] = None, monotone_mode: Optional[str] = None, theta: float = 0.5, old_frame_traceback: bool = False, + transport: str = "backward_nodes", ): ## Parent class will set up default values etc super().__init__( @@ -4519,8 +4563,14 @@ def __init__( ## NB - Smoothing is generally required for stability. 0.0001 is effective ## at the various resolutions tested. - if DuDt is None: - self.Unknowns.DuDt = SemiLagrangian_DDt( + if transport not in _SEMI_LAGRANGIAN_TRANSPORTS: + raise ValueError(f"transport must be one of {_SEMI_LAGRANGIAN_TRANSPORTS}, " + f"not {transport!r}") + if DuDt is not None and transport != "backward_nodes": + raise ValueError("transport chooses the DuDt the solver builds; it cannot " + "apply to a DuDt that is supplied") + if DuDt is None and transport == "backward_nodes": + self.Unknowns.DuDt = BackwardNodesSemiLagrangian( self.mesh, u_Field.sym, # Symbolic expression - SemiLagrangian evaluates this at each update self._V_fn, @@ -4536,6 +4586,29 @@ def __init__( theta=theta, old_frame_traceback=old_frame_traceback, ) + elif DuDt is None: + if not u_Field.continuous: + raise NotImplementedError( + f"transport={transport!r} holds a continuous history; " + "use transport='backward_nodes' for a discontinuous field") + # options a scheme does not take are refused by ddt.SemiLagrangian, + # so only those that were asked for are passed on + asked = {k: v for k, v in (("monotone_mode", monotone_mode), + ("old_frame_traceback", old_frame_traceback)) if v} + trace, launch = transport.split("_", 1) + self.Unknowns.DuDt = uw.systems.ddt.SemiLagrangian( + self.mesh, + u_Field.sym, + self._V_fn, + uw.VarType.SCALAR, + trace=trace, + launch=launch, + degree=u_Field.degree, + varsymbol=u_Field.symbol, + order=1, + theta=theta, + **asked, + ) else: # validation @@ -4554,7 +4627,7 @@ def __init__( # flux vector (volume meshes have dim==cdim so this is # unchanged; manifold meshes have cdim > dim and need the # extra component). - self.Unknowns.DFDt = SemiLagrangian_DDt( + self.Unknowns.DFDt = BackwardNodesSemiLagrangian( self.mesh, sympy.Matrix([[0] * self.mesh.cdim]), # Actual function is not defined at this point self._V_fn, @@ -4585,7 +4658,7 @@ def __init__( # ALE trace-back path (and REMAP it correctly on an OT opt-out # reset). Only meaningful when DuDt traces back (SemiLagrangian); # Eulerian/Lagrangian fields keep the default policy. - if isinstance(self.Unknowns.DuDt, SemiLagrangian_DDt): + if isinstance(self.Unknowns.DuDt, BackwardNodesSemiLagrangian): from underworld3.discretisation.remesh import RemeshPolicy self.u.remesh_policy = RemeshPolicy.CARRY self.u._remesh_managed_by = self.Unknowns.DuDt @@ -5047,9 +5120,9 @@ class SNES_Diffusion(SNES_Scalar): Numerically evaluate symbolic expressions during setup. verbose : bool, default=False Enable verbose output. - DuDt : Eulerian_DDt, SemiLagrangian_DDt, or Lagrangian_DDt, optional + DuDt : Eulerian_DDt, BackwardNodesSemiLagrangian, or Lagrangian_DDt, optional Time derivative operator for the unknown. - DFDt : Eulerian_DDt, SemiLagrangian_DDt, or Lagrangian_DDt, optional + DFDt : Eulerian_DDt, BackwardNodesSemiLagrangian, or Lagrangian_DDt, optional Time derivative operator for the flux. Notes @@ -5081,8 +5154,8 @@ def __init__( theta: float = 0.0, evalf: Optional[bool] = False, verbose=False, - DuDt: Union[Eulerian_DDt, SemiLagrangian_DDt, Lagrangian_DDt] = None, - DFDt: Union[Eulerian_DDt, SemiLagrangian_DDt, Lagrangian_DDt] = None, + DuDt: Union[Eulerian_DDt, BackwardNodesSemiLagrangian, Lagrangian_DDt] = None, + DFDt: Union[Eulerian_DDt, BackwardNodesSemiLagrangian, Lagrangian_DDt] = None, ): ## Parent class will set up default values etc super().__init__( @@ -5373,9 +5446,9 @@ class SNES_NavierStokes(SNES_Stokes_SaddlePt): If False, use discontinuous pressure elements. verbose : bool, default=False Enable verbose output. - DuDt : SemiLagrangian_DDt or Lagrangian_DDt, optional + DuDt : BackwardNodesSemiLagrangian or Lagrangian_DDt, optional Time derivative operator for velocity. - DFDt : SemiLagrangian_DDt or Lagrangian_DDt, optional + DFDt : BackwardNodesSemiLagrangian or Lagrangian_DDt, optional Time derivative operator for stress. Notes @@ -5421,8 +5494,8 @@ def __init__( flux_order: Optional[int] = None, p_continuous: Optional[bool] = False, verbose: Optional[bool] = False, - DuDt: Union[SemiLagrangian_DDt, Lagrangian_DDt] = None, - DFDt: Union[SemiLagrangian_DDt, Lagrangian_DDt] = None, + DuDt: Union[BackwardNodesSemiLagrangian, Lagrangian_DDt] = None, + DFDt: Union[BackwardNodesSemiLagrangian, Lagrangian_DDt] = None, ): ## Parent class will set up default values and load u_Field into the solver super().__init__( @@ -5637,7 +5710,7 @@ def DuDt(self): @DuDt.setter def DuDt( self, - DuDt_value: Union[SemiLagrangian_DDt, Lagrangian_DDt], + DuDt_value: Union[BackwardNodesSemiLagrangian, Lagrangian_DDt], ): """Set the time derivative operator for velocity.""" self.Unknowns.DuDt = DuDt_value diff --git a/tests/parallel/test_1062_forward_stress_history_mpi.py b/tests/parallel/test_1062_forward_stress_history_mpi.py index db421412f..8327099b3 100644 --- a/tests/parallel/test_1062_forward_stress_history_mpi.py +++ b/tests/parallel/test_1062_forward_stress_history_mpi.py @@ -21,7 +21,7 @@ POINTS = np.array([[0.5, 0.2], [-0.3, -0.35]]) -def turned_over_maxwell_box(transport="forward", steps=10, dt=0.1): +def turned_over_maxwell_box(transport="forward_integration_points", steps=10, dt=0.1): Lx, H = 1.0, 0.5 mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(-Lx, -H), maxCoords=(Lx, H), cellSize=0.125, qdegree=3, regular=False) @@ -48,7 +48,7 @@ def turned_over_maxwell_box(transport="forward", steps=10, dt=0.1): def test_the_forward_history_gives_the_serial_stress_on_every_rank(): kind, values, relocated = turned_over_maxwell_box() - assert kind == "ForwardSemiLagrangian" + assert kind == "ForwardIntegrationPointsSemiLagrangian" # The serial values are the hard baseline (they pin the physics; regenerate # them if a default changes). Parallel matches them to 1e-4, not to # round-off: a cell's arrivals are summed into its least-squares fit in an diff --git a/tests/test_0066_integration_point_slcn.py b/tests/test_0066_integration_point_slcn.py index 1aefe935c..d2b7df65a 100644 --- a/tests/test_0066_integration_point_slcn.py +++ b/tests/test_0066_integration_point_slcn.py @@ -31,7 +31,7 @@ def test_slots_are_exact_departure_point_values(): V = sympy.Matrix([[v[0], v[1]]]) dt = 0.1 - ddt = uw.systems.ddt.IntegrationPointSemiLagrangian(mesh, T, V, degree=2, order=2) + ddt = uw.systems.ddt.BackwardIntegrationPointsSemiLagrangian(mesh, T, V, degree=2, order=2) assert all(ps.is_integration_point for ps in ddt.psi_star) ddt.update_pre_solve(dt) @@ -70,7 +70,7 @@ def _rotating_gaussian(mesh, kind, dt, nsteps): T = uw.discretisation.MeshVariable(f"T_{kind}", mesh, 1, degree=2) T.data[:, 0] = gauss(np.asarray(T.coords), x0, 0.0) if kind == "ip": - DuDt = uw.systems.ddt.IntegrationPointSemiLagrangian(mesh, T, V, degree=2, order=1) + DuDt = uw.systems.ddt.BackwardIntegrationPointsSemiLagrangian(mesh, T, V, degree=2, order=1) adv = uw.systems.AdvDiffusionSLCN(mesh, u_Field=T, V_fn=V, DuDt=DuDt, order=1) else: adv = uw.systems.AdvDiffusionSLCN(mesh, u_Field=T, V_fn=V, order=1) @@ -97,10 +97,10 @@ def test_undersampled_rule_is_refused(): T = uw.discretisation.MeshVariable("T", mesh, 1, degree=2) V = sympy.Matrix([[1.0, 0.0]]) with pytest.raises(RuntimeError, match="oversampled"): - uw.systems.ddt.IntegrationPointSemiLagrangian(mesh, T, V, degree=2) + uw.systems.ddt.BackwardIntegrationPointsSemiLagrangian(mesh, T, V, degree=2) # P1 on the same rule is 2x oversampled and accepted. T1 = uw.discretisation.MeshVariable("T1", mesh, 1, degree=1) - uw.systems.ddt.IntegrationPointSemiLagrangian(mesh, T1, V, degree=1) + uw.systems.ddt.BackwardIntegrationPointsSemiLagrangian(mesh, T1, V, degree=1) @pytest.mark.level_2 @@ -136,7 +136,7 @@ def _unsteady_uniform_flow_check(kind, vform="var"): "ramp": (c * v_var.sym, 1.0)}[vform] if kind == "ip": - ddt = uw.systems.ddt.IntegrationPointSemiLagrangian(mesh, T, V_fn, degree=2, order=1) + ddt = uw.systems.ddt.BackwardIntegrationPointsSemiLagrangian(mesh, T, V_fn, degree=2, order=1) else: ddt = uw.systems.ddt.SemiLagrangian(mesh, T, V_fn, uw.VarType.SCALAR, degree=2, continuous=True, order=1) @@ -200,7 +200,7 @@ def test_composed_advdiffusion_reachability(config): def run(solver_cls, kwargs): T = uw.discretisation.MeshVariable(f"T_{config}_{solver_cls.__name__}", mesh, 1, degree=2) T.data[:, 0] = gauss(np.asarray(T.coords)) - D = uw.systems.ddt.IntegrationPointSemiLagrangian(mesh, T, V, degree=2, order=order, theta=theta) + D = uw.systems.ddt.BackwardIntegrationPointsSemiLagrangian(mesh, T, V, degree=2, order=order, theta=theta) adv = solver_cls(mesh, u_Field=T, V_fn=V, DuDt=D, order=order, **kwargs) adv.constitutive_model = uw.constitutive_models.DiffusionModel adv.constitutive_model.Parameters.diffusivity = 1e-9 @@ -357,7 +357,7 @@ def test_a_vector_history_holds_the_departure_point_values(): with uw.synchronised_array_update(): U.data[...] = _vector_field(np.asarray(U.coords)) - ddt = uw.systems.ddt.IntegrationPointSemiLagrangian( + ddt = uw.systems.ddt.BackwardIntegrationPointsSemiLagrangian( mesh, U, _velocity(), vtype=uw.VarType.VECTOR, degree=2, order=2) assert ddt.num_components == 2 assert all(ps.is_integration_point for ps in ddt.psi_star) @@ -390,7 +390,7 @@ def test_a_symmetric_tensor_history_transports_every_component(): with uw.synchronised_array_update(): S.data[...] = _pack(_tensor_entries(np.asarray(S.coords)), columns) - ddt = uw.systems.ddt.IntegrationPointSemiLagrangian( + ddt = uw.systems.ddt.BackwardIntegrationPointsSemiLagrangian( mesh, S, _velocity(), vtype=uw.VarType.SYM_TENSOR, degree=2, order=1) assert ddt.num_components == 3 assert ddt._components == columns @@ -425,7 +425,7 @@ def test_a_scalar_history_is_unchanged(): with uw.synchronised_array_update(): T.data[:, 0] = _scalar_field(np.asarray(T.coords)) - ddt = uw.systems.ddt.IntegrationPointSemiLagrangian( + ddt = uw.systems.ddt.BackwardIntegrationPointsSemiLagrangian( mesh, T, _velocity(), degree=2, order=1) assert ddt.num_components == 1 assert ddt.bdf().shape == (1, 1) @@ -449,7 +449,7 @@ def test_the_history_symbol_participates_in_expressions(vtype): columns = _storage_components(vtype, tuple(var.sym.shape)) with uw.synchronised_array_update(): var.data[...] = 2.0 - ddt = uw.systems.ddt.IntegrationPointSemiLagrangian( + ddt = uw.systems.ddt.BackwardIntegrationPointsSemiLagrangian( mesh, var, _velocity(), vtype=vtype, degree=2, order=1) ddt.update_pre_solve(DT) @@ -470,7 +470,7 @@ def test_the_refusal_is_gone_but_the_rule_check_is_not(): mesh = uw.meshing.UnstructuredSimplexBox(cellSize=0.2, qdegree=2) U = uw.discretisation.MeshVariable("Ur", mesh, vtype=uw.VarType.VECTOR, degree=2) with pytest.raises(RuntimeError, match="qdegree|rule|oversample"): - uw.systems.ddt.IntegrationPointSemiLagrangian( + uw.systems.ddt.BackwardIntegrationPointsSemiLagrangian( mesh, U, _velocity(), vtype=uw.VarType.VECTOR, degree=2, order=1) @@ -492,12 +492,12 @@ def test_a_vtype_that_does_not_match_psi_fn_is_refused(): before = len(mesh.vars) with pytest.raises(ValueError, match="psi_fn has shape"): - uw.systems.ddt.IntegrationPointSemiLagrangian( + uw.systems.ddt.BackwardIntegrationPointsSemiLagrangian( mesh, sympy.Matrix([[1.0]]), _velocity(), vtype=uw.VarType.VECTOR, degree=2, order=1) with pytest.raises(ValueError, match="psi_fn has shape"): - uw.systems.ddt.IntegrationPointSemiLagrangian( + uw.systems.ddt.BackwardIntegrationPointsSemiLagrangian( mesh, sympy.Matrix([[1.0, 2.0]]), _velocity(), vtype=uw.VarType.SYM_TENSOR, degree=2, order=1) @@ -513,7 +513,7 @@ def test_the_shape_guard_is_on_the_setter_not_only_the_constructor(): mesh = uw.meshing.UnstructuredSimplexBox(cellSize=0.25, qdegree=3) S = uw.discretisation.MeshVariable("Sg", mesh, vtype=uw.VarType.SYM_TENSOR, degree=2) - ddt = uw.systems.ddt.IntegrationPointSemiLagrangian( + ddt = uw.systems.ddt.BackwardIntegrationPointsSemiLagrangian( mesh, S, _velocity(), vtype=uw.VarType.SYM_TENSOR, degree=2, order=1) with pytest.raises(ValueError, match="psi_fn has shape"): @@ -534,7 +534,7 @@ def test_a_full_tensor_is_not_accepted_as_a_symmetric_one(): full = uw.discretisation.MeshVariable("Tg", mesh, vtype=uw.VarType.TENSOR, degree=2) with pytest.raises(ValueError, match="stores 4 components"): - uw.systems.ddt.IntegrationPointSemiLagrangian( + uw.systems.ddt.BackwardIntegrationPointsSemiLagrangian( mesh, full, _velocity(), vtype=uw.VarType.SYM_TENSOR, degree=2, order=1) @@ -550,7 +550,7 @@ def test_an_asymmetric_psi_fn_under_sym_tensor_says_so(): asymmetric = sympy.Matrix([[1 + x, 2 + y], [100.0, 3 + x * y]]) with pytest.warns(UserWarning, match="not symmetric"): - uw.systems.ddt.IntegrationPointSemiLagrangian( + uw.systems.ddt.BackwardIntegrationPointsSemiLagrangian( mesh, asymmetric, _velocity(), vtype=uw.VarType.SYM_TENSOR, degree=2, order=1) @@ -558,6 +558,6 @@ def test_an_asymmetric_psi_fn_under_sym_tensor_says_so(): symmetric = sympy.Matrix([[1 + x, 2 + y], [2 + y, 3 + x * y]]) with warnings.catch_warnings(): warnings.simplefilter("error", UserWarning) - uw.systems.ddt.IntegrationPointSemiLagrangian( + uw.systems.ddt.BackwardIntegrationPointsSemiLagrangian( mesh, symmetric, _velocity(), vtype=uw.VarType.SYM_TENSOR, degree=2, order=1) diff --git a/tests/test_1056_units_slcn_traceback.py b/tests/test_1056_units_slcn_traceback.py index c8ae585d3..4c0d827d3 100644 --- a/tests/test_1056_units_slcn_traceback.py +++ b/tests/test_1056_units_slcn_traceback.py @@ -53,7 +53,7 @@ def _advect_blob(use_units, vy=20.0, nsteps=5, dt=2.0, scheme="slcn"): T.data[:, 0] = np.exp(-(((c[:, 0] - 500) / 120) ** 2 + ((c[:, 1] - 300) / 120) ** 2)) if scheme == "slcn_ip": - DuDt = uw.systems.ddt.IntegrationPointSemiLagrangian(mesh, T, V.sym, degree=2, order=1) + DuDt = uw.systems.ddt.BackwardIntegrationPointsSemiLagrangian(mesh, T, V.sym, degree=2, order=1) adv = uw.systems.AdvDiffusionSLCN(mesh, u_Field=T, V_fn=V.sym, DuDt=DuDt, order=1) else: adv = uw.systems.AdvDiffusionSLCN(mesh, u_Field=T, V_fn=V.sym) diff --git a/tests/test_1059_stress_transport.py b/tests/test_1059_stress_transport.py index f9d14cc55..099d14095 100644 --- a/tests/test_1059_stress_transport.py +++ b/tests/test_1059_stress_transport.py @@ -223,9 +223,9 @@ def _maxwell_shear(transport, order, steps=20, dt=0.1, integrator="bdf", solver= KINDS = { - "semi_lagrangian": "SemiLagrangian", - "integration_point": "IntegrationPointSemiLagrangian", - "forward": "ForwardSemiLagrangian", + "backward_nodes": "BackwardNodesSemiLagrangian", + "backward_integration_points": "BackwardIntegrationPointsSemiLagrangian", + "forward_integration_points": "ForwardIntegrationPointsSemiLagrangian", "lagrangian": "Lagrangian", "eulerian": "EulerianSUPG", } @@ -247,8 +247,8 @@ def test_every_stress_history_solves_the_maxwell_shear_box(order, integrator, to for transport, expected_kind in KINDS.items(): if integrator == "etd" and order == 2 and transport == "eulerian": continue # the grid flavour has no forcing-history slot yet - if order == 2 and transport == "forward": - continue # the forward flavour carries one level + if order == 2 and transport.startswith("forward_"): + continue # the forward flavours carry one level if transport == "lagrangian" and (order == 2 or integrator == "etd"): continue # order 1 BDF only for now (no exponential coefficients # on the particle flavour; order 2 deferred) @@ -278,7 +278,46 @@ def test_stress_transport_is_validated_and_fixed_once_the_history_exists(): stokes.constitutive_model.Parameters.dt_elastic = 0.1 assert type(stokes.DFDt).__name__ == "EulerianSUPG" with pytest.raises(RuntimeError, match="already exists"): - stokes.stress_transport = "semi_lagrangian" + stokes.stress_transport = "backward_nodes" + + +def test_the_former_stress_transport_names_still_select_their_scheme(): + mesh = uw.meshing.StructuredQuadBox(elementRes=(4, 4)) + v = uw.discretisation.MeshVariable("U_old", mesh, mesh.dim, degree=2) + p = uw.discretisation.MeshVariable("P_old", mesh, 1, degree=1) + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + for old, new in (("semi_lagrangian", "backward_nodes"), + ("integration_point", "backward_integration_points"), + ("forward", "forward_integration_points")): + with pytest.warns(FutureWarning, match=new): + stokes.stress_transport = old + assert stokes.stress_transport == new + # a stress is formed at the integration points: it is not carried from the nodes + with pytest.raises(ValueError, match="stress_transport must be"): + stokes.stress_transport = "forward_nodes" + + +def test_semi_lagrangian_selects_its_scheme_by_trace_and_launch(): + mesh = uw.meshing.UnstructuredSimplexBox(cellSize=0.25) + T = uw.discretisation.MeshVariable("T_sel", mesh, 1, degree=1) + V = sympy.Matrix([[1.0, 0.0]]) + for (trace, launch), kind in ( + (("backward", "nodes"), "BackwardNodesSemiLagrangian"), + (("backward", "integration_points"), "BackwardIntegrationPointsSemiLagrangian"), + (("forward", "integration_points"), "ForwardIntegrationPointsSemiLagrangian"), + (("forward", "nodes"), "ForwardNodesSemiLagrangian")): + history = uw.systems.ddt.SemiLagrangian(mesh, T.sym, V, uw.VarType.SCALAR, + trace=trace, launch=launch, degree=1) + assert type(history).__name__ == kind + with pytest.raises(ValueError, match="no semi-Lagrangian scheme"): + uw.systems.ddt.SemiLagrangian(mesh, T.sym, V, trace="sideways") + # an option the chosen scheme does not take is refused, not dropped + with pytest.raises(TypeError, match="monotone_mode"): + uw.systems.ddt.SemiLagrangian(mesh, T.sym, V, trace="forward", degree=1, monotone_mode="clamp") + with pytest.raises(NotImplementedError, match="integration points"): + uw.systems.ddt.SemiLagrangian(mesh, T.sym, V, trace="forward", degree=1).commit_flux_to_history(T.sym) + with pytest.warns(FutureWarning, match="ForwardIntegrationPointsSemiLagrangian"): + assert uw.systems.ddt.ForwardSemiLagrangian is uw.systems.ddt.ForwardIntegrationPointsSemiLagrangian def _sheared_varying_modulus(transport, order, steps=10, dt=0.1, res=6): @@ -324,7 +363,7 @@ def _sheared_varying_modulus(transport, order, steps=10, dt=0.1, res=6): def test_the_two_stress_histories_agree_when_the_stress_moves_and_evolves(): """With a stress that is carried as well as relaxed the schemes must agree to within their own time-discretisation error, and more tightly at order 2.""" - traced, traced_slope = _sheared_varying_modulus("semi_lagrangian", 2) + traced, traced_slope = _sheared_varying_modulus("backward_nodes", 2) grid, grid_slope = _sheared_varying_modulus("eulerian", 2) assert traced_slope > 0.1 and grid_slope > 0.1, "the stress must not be uniform" # BASELINES (the L2 norm of the shear stress; see the ledger): each scheme @@ -368,9 +407,9 @@ def run(transport): return type(ns.DFDt).__name__, float(np.asarray(uw.function.evaluate( ns.DFDt.psi_star[0].sym[0, 1], np.array([[0.0, 0.0]]))).reshape(-1)[0]) - traced_kind, traced = run("semi_lagrangian") + traced_kind, traced = run("backward_nodes") grid_kind, grid = run("eulerian") - assert traced_kind == "SemiLagrangian" and grid_kind == "EulerianSUPG" + assert traced_kind == "BackwardNodesSemiLagrangian" and grid_kind == "EulerianSUPG" # BASELINE: the shear stress at the origin after ten steps (see the ledger) assert abs(traced - NS_ORDER2_XY) < 1.0e-3 * NS_ORDER2_XY, traced assert abs(grid - traced) / traced < 1.0e-3, (grid, traced) @@ -429,7 +468,7 @@ def run(transport): return float(np.asarray(uw.function.evaluate( ns.DFDt.psi_star[0].sym[0, 1], np.array([[0.0, 0.0]]))).reshape(-1)[0]) - traced, grid = run("semi_lagrangian"), run("eulerian") + traced, grid = run("backward_nodes"), run("eulerian") # BASELINE: the shear stress at the origin after ten steps (see the ledger) assert abs(traced - CN_ORDER1_XY) < 1.0e-3 * CN_ORDER1_XY, traced assert abs(grid - traced) / traced < 1.0e-3, (grid, traced) @@ -442,7 +481,7 @@ def test_devss_is_off_by_default_and_vanishes_on_a_uniform_strain_rate(monkeypat off unless asked for. The varying-modulus box is the negative control: there D differs from edot by projection error, the term is live, and the answer must move -- by projection error, which is small, but not by nothing.""" - _kind, off, exact = _maxwell_shear("integration_point", 1) + _kind, off, exact = _maxwell_shear("backward_integration_points", 1) assert abs(off - exact) / exact < 0.02 def with_devss(builder, *args, **kw): @@ -456,14 +495,14 @@ def patched(self, *a, **k): m.setattr(uw.systems.Stokes, "__init__", patched) return builder(*args, **kw) - _kind, on, _ = with_devss(_maxwell_shear, "integration_point", 1) + _kind, on, _ = with_devss(_maxwell_shear, "backward_integration_points", 1) assert abs(on - off) < 1e-8 * abs(exact), (on, off) # the pair cancelled # the varying-modulus helper differentiates the history in a weak form, # which an integration-point variable refuses; the term is on the solver # and flavour-independent, so the nodal history serves for this half - norm_off, _ = _sheared_varying_modulus("semi_lagrangian", 1) - norm_on, _ = with_devss(_sheared_varying_modulus, "semi_lagrangian", 1) + norm_off, _ = _sheared_varying_modulus("backward_nodes", 1) + norm_on, _ = with_devss(_sheared_varying_modulus, "backward_nodes", 1) moved = abs(norm_on - norm_off) / norm_off assert 1e-6 < moved < 5e-2, moved # live, and only projection-sized @@ -512,7 +551,7 @@ def test_the_exponential_integrator_runs_on_the_trace_back_navier_stokes(): as the Stokes family does; it did neither, so the memory term was absent and the exponential integrator ran in its viscous limit (#741).""" for integrator, tolerance in (("etd", 1e-3), ("bdf", 0.02)): - _, stress, exact = _maxwell_shear("semi_lagrangian", 1, integrator=integrator, solver="ns_slcn") + _, stress, exact = _maxwell_shear("backward_nodes", 1, integrator=integrator, solver="ns_slcn") assert abs(stress - exact) / exact < tolerance, (integrator, stress, exact) @@ -523,8 +562,8 @@ def test_a_preset_velocity_gives_both_integrators_the_same_first_stress(): exponential one read alpha = phi = 0 and recorded the full viscous stress, seven times the BDF value on the cylinder (#740). One step: the two first-order integrators agree to O(dt / t_r).""" - _, bdf, _ = _maxwell_shear("semi_lagrangian", 1, steps=1, integrator="bdf", initial_velocity=True) - _, etd, _ = _maxwell_shear("semi_lagrangian", 1, steps=1, integrator="etd", initial_velocity=True) + _, bdf, _ = _maxwell_shear("backward_nodes", 1, steps=1, integrator="bdf", initial_velocity=True) + _, etd, _ = _maxwell_shear("backward_nodes", 1, steps=1, integrator="etd", initial_velocity=True) # gammadot = 1, eta = lambda = 1, dt = 0.1. The history's first level is the # constitutive flux of the preset velocity (#740), so one step gives # BDF-1: eta_eff gdot (1 + eta_eff / (mu dt)) = (1/11)(1 + 10/11) = 21/121 @@ -548,7 +587,7 @@ def test_the_integration_point_history_takes_the_inflow_value_at_an_inlet(): incoming = sympy.Matrix([[1.0, 0.25], [0.25, -1.0]]) results = {} for tag, inflow in (("with", incoming), ("without", None)): - manager = uw.systems.ddt.IntegrationPointSemiLagrangian( + manager = uw.systems.ddt.BackwardIntegrationPointsSemiLagrangian( mesh, sympy.Matrix.zeros(2, 2), velocity, vtype=uw.VarType.SYM_TENSOR, degree=1, continuous=True, order=1, varsymbol=rf"S^{{{tag}}}") assert manager.applies_inflow_value @@ -573,7 +612,7 @@ def test_the_integration_point_history_takes_the_inflow_value_at_an_inlet(): -@pytest.mark.parametrize("transport", ["semi_lagrangian", "integration_point"]) +@pytest.mark.parametrize("transport", ["backward_nodes", "backward_integration_points"]) def test_the_upper_convected_element_builds_the_first_normal_stress_in_shear(transport): """Start-up of simple shear for the UCM fluid has the closed form sigma_xy = eta gdot (1 - e^{-t/lambda}) and @@ -626,7 +665,7 @@ def test_a_solvent_viscosity_adds_its_newtonian_stress(): shear cannot tell (any uniform stress satisfies momentum): the channel flow's speed is set by the total viscosity.""" steps, dt, eta_s = 20, 0.1, 0.5 - _, polymer_xy, polymer_exact, _, solvent_xy = _maxwell_shear("semi_lagrangian", 1, steps=steps, dt=dt, solvent=eta_s) + _, polymer_xy, polymer_exact, _, solvent_xy = _maxwell_shear("backward_nodes", 1, steps=steps, dt=dt, solvent=eta_s) gdot = 1.0 assert abs(polymer_xy - polymer_exact) / polymer_exact < 0.02, (polymer_xy, polymer_exact) assert abs(solvent_xy - eta_s * gdot) < 1e-6, solvent_xy @@ -671,7 +710,7 @@ def _one_shear_step(transport, dt=1.0, objective_rate="none"): return stokes -@pytest.mark.parametrize("transport", ["semi_lagrangian", "integration_point", "forward"]) +@pytest.mark.parametrize("transport", ["backward_nodes", "backward_integration_points", "forward_integration_points"]) def test_the_conformation_after_one_shear_step_is_one_minus_half_the_step(transport): stokes = _one_shear_step(transport, dt=1.0) health = stokes.constitutive_model.conformation_min_eigenvalue() @@ -693,7 +732,7 @@ def test_the_conformation_check_sees_a_lost_conformation(): v = uw.discretisation.MeshVariable("U_lost", mesh, mesh.dim, degree=2) p = uw.discretisation.MeshVariable("P_lost", mesh, 1, degree=1) stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p, verbose=False) - stokes.stress_transport = "integration_point" + stokes.stress_transport = "backward_integration_points" stokes.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( stokes.Unknowns, order=1, integrator="bdf") stokes.constitutive_model.Parameters.shear_viscosity_0 = eta @@ -712,7 +751,7 @@ def test_the_conformation_check_sees_a_lost_conformation(): assert health["where"] is not None -@pytest.mark.parametrize("transport", ["semi_lagrangian", "integration_point", "forward"]) +@pytest.mark.parametrize("transport", ["backward_nodes", "backward_integration_points", "forward_integration_points"]) def test_the_elastic_timestep_is_the_safety_factor_over_the_shear_rate(transport): stokes = _one_shear_step(transport, dt=1.0, objective_rate="upper_convected") # gammadot = 2 speed / height = 1 everywhere: dt_max = safety / 1. The rate is @@ -724,7 +763,7 @@ def test_the_elastic_timestep_is_the_safety_factor_over_the_shear_rate(transport def test_the_store_smoothing_is_the_coefficient_times_the_local_cell_size_squared(): - stokes = _one_shear_step("integration_point", dt=1.0) + stokes = _one_shear_step("backward_integration_points", dt=1.0) history = stokes.DFDt assert history.store_smoothing == 0.0 assert history._commit_projection.smoothing == 0.0 diff --git a/tests/test_1060_stress_store_smoothing.py b/tests/test_1060_stress_store_smoothing.py index 90b93db0c..f5bfc3346 100644 --- a/tests/test_1060_stress_store_smoothing.py +++ b/tests/test_1060_stress_store_smoothing.py @@ -30,7 +30,7 @@ def _cell_scale_content(history, mesh): return rms(raw - fit) / max(rms(raw), 1.0e-300) -def waters_king_start_up(store_smoothing, res=16, dt=0.0125, t_end=2.0, transport="integration_point", +def waters_king_start_up(store_smoothing, res=16, dt=0.0125, t_end=2.0, transport="backward_integration_points", return_kind=False): """Waters and King start-up on the integration-point history, pure Maxwell, below Courant one. Returns u at the centre at t 1, the cell-scale content @@ -50,15 +50,15 @@ def waters_king_start_up(store_smoothing, res=16, dt=0.0125, t_end=2.0, transpor ns.add_dirichlet_bc((0.0, 0.0), "Top"); ns.add_dirichlet_bc((0.0, 0.0), "Bottom") ns.add_dirichlet_bc((sympy.oo, 0.0), "Left"); ns.add_dirichlet_bc((sympy.oo, 0.0), "Right") ns.bodyforce = sympy.Matrix([[G, 0.0]]); ns.tolerance = 1e-6 - if transport == "integration_point": + if transport == "backward_integration_points": ns.DFDt.store_smoothing = store_smoothing - elif transport == "forward": + elif transport == "forward_integration_points": ns.DFDt.flux_smoothing = store_smoothing * mesh.cell_size() ** 2 # The content has to be read after the trace-back and before the solve: after # the store the point values are a P1 field sampled at the points and the # cell-scale part is zero by construction, whatever the run is doing. latest = {"content": float("nan")} - if transport == "integration_point": + if transport == "backward_integration_points": carry = ns.DFDt.update_pre_solve def carry_and_measure(*args, **kwargs): out = carry(*args, **kwargs) @@ -95,7 +95,7 @@ def test_the_store_smoothing_holds_the_cell_scale_mode_of_the_integration_point_ """ u_plain, plain, _, kind = waters_king_start_up(0.0, return_kind=True) u_smooth, smooth, _ = waters_king_start_up(0.07) - assert kind == "IntegrationPointSemiLagrangian" + assert kind == "BackwardIntegrationPointsSemiLagrangian" growth = plain[2.0] / plain[1.0] assert 4.0 < growth < 20.0, growth # e^{gamma}, gamma between 1.4 and 3 per unit time assert smooth[2.0] < smooth[1.0], smooth # held: decaying, not growing diff --git a/tests/test_1061_stress_forward_history.py b/tests/test_1061_stress_forward_history.py index 195cacc26..e5af9c331 100644 --- a/tests/test_1061_stress_forward_history.py +++ b/tests/test_1061_stress_forward_history.py @@ -16,8 +16,8 @@ def test_the_forward_history_with_its_read_back_smoothing_holds_the_maxwell_start_up(): - u1, _, u65, kind = waters_king_start_up(0.023, t_end=6.5, transport="forward", return_kind=True) - assert kind == "ForwardSemiLagrangian" + u1, _, u65, kind = waters_king_start_up(0.023, t_end=6.5, transport="forward_integration_points", return_kind=True) + assert kind == "ForwardIntegrationPointsSemiLagrangian" # measured with this dose at 1/16, dt 0.0125: 0.95427 at t 1.00, 0.51851 at t 6.5 # (nodal 0.96215 and 0.51710) assert abs(u1 - 0.9543) < 0.003 diff --git a/tests/test_1063_stress_history_restart.py b/tests/test_1063_stress_history_restart.py index 24db68a26..2c6340712 100644 --- a/tests/test_1063_stress_history_restart.py +++ b/tests/test_1063_stress_history_restart.py @@ -42,7 +42,7 @@ def _stress_at_origin(stokes): return float(np.asarray(uw.function.evaluate(stokes.DFDt.psi_star[0].sym[0, 1], np.array([[0.0, 0.0]]))).reshape(-1)[0]) -@pytest.mark.parametrize("transport", ["semi_lagrangian", "integration_point", "forward", "lagrangian"]) +@pytest.mark.parametrize("transport", ["backward_nodes", "backward_integration_points", "forward_integration_points", "lagrangian"]) def test_a_restored_history_continues_where_it_left_off(transport): orchestration_model, stokes, dt = _shear_box(transport) for _ in range(6): diff --git a/tests/test_1064_stress_history_units.py b/tests/test_1064_stress_history_units.py index 6d72401f9..e825b939a 100644 --- a/tests/test_1064_stress_history_units.py +++ b/tests/test_1064_stress_history_units.py @@ -24,8 +24,8 @@ STRESS_SCALE_PA = 1.0e21 / MYR_S STEPS = 12 ORIGIN = np.array([[0.0, 0.0]]) -CASES = [(t, 1) for t in ("semi_lagrangian", "integration_point", "forward", "lagrangian", "eulerian")] \ - + [(t, 2) for t in ("semi_lagrangian", "integration_point", "eulerian")] +CASES = [(t, 1) for t in ("backward_nodes", "backward_integration_points", "forward_integration_points", "lagrangian", "eulerian")] \ + + [(t, 2) for t in ("backward_nodes", "backward_integration_points", "eulerian")] def _shear_box(transport, order, with_units, **model_options): @@ -35,7 +35,7 @@ def _shear_box(transport, order, with_units, **model_options): length=uw.quantity(1.0, "km"), viscosity=uw.quantity(1.0e21, "Pa*s"), time=uw.quantity(1.0, "Myr")) mesh = uw.meshing.StructuredQuadBox(elementRes=(16, 8), minCoords=(-1.0, -0.5), maxCoords=(1.0, 0.5)) - tag = f"{transport[:3]}{order}{'u' if with_units else 'n'}" + tag = f"{"".join(w[0] for w in transport.split("_"))}{order}{'u' if with_units else 'n'}" v = uw.discretisation.MeshVariable(f"U_{tag}", mesh, 2, degree=2, units="km/Myr" if with_units else None) p = uw.discretisation.MeshVariable(f"P_{tag}", mesh, 1, degree=1, units="Pa" if with_units else None) stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) @@ -86,7 +86,7 @@ def test_units_model_history_is_the_nondimensional_history(transport, order): assert abs(value_pa - expected_pa) < 1.0e-6 * abs(expected_pa), (transport, order, value_pa, expected_pa) -@pytest.mark.parametrize("transport", ["semi_lagrangian", "forward"]) +@pytest.mark.parametrize("transport", ["backward_nodes", "forward_integration_points"]) def test_units_model_log_conformation_history(transport): """The log-conformation record is dimensionless: the same in both runs; the stress the model reads from it, G (exp(psi) - I), is in Pa.""" diff --git a/tests/test_1065_log_conformation.py b/tests/test_1065_log_conformation.py index ba14f502e..47b5b8698 100644 --- a/tests/test_1065_log_conformation.py +++ b/tests/test_1065_log_conformation.py @@ -59,7 +59,7 @@ def _extension_step(transport, convected_step, stress_history, integrator="bdf") uw.reset_default_model() mesh = uw.meshing.StructuredQuadBox(elementRes=(4, 4), minCoords=(-1, -1), maxCoords=(1, 1)) x, y = mesh.X - tag = f"{transport[:3]}{convected_step[:3]}{stress_history[:3]}{integrator}" + tag = f"{"".join(w[0] for w in transport.split("_"))}{convected_step[:3]}{stress_history[:3]}{integrator}" v = uw.discretisation.MeshVariable(f"U_{tag}", mesh, 2, degree=2) p = uw.discretisation.MeshVariable(f"P_{tag}", mesh, 1, degree=1) stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) @@ -89,7 +89,7 @@ def _extension_step(transport, convected_step, stress_history, integrator="bdf") @pytest.mark.parametrize("integrator", ["bdf", "etd"]) @pytest.mark.parametrize("transport, stress_history", [ - ("semi_lagrangian", "stress"), ("semi_lagrangian", "log_conformation"), + ("backward_nodes", "stress"), ("backward_nodes", "log_conformation"), ("eulerian", "stress"), ("eulerian", "log_conformation")]) def test_deformation_step_is_the_closed_form(transport, stress_history, integrator): c = _deformation_step(np.eye(2), np.diag([RATE, -RATE]), DT, 1.0, integrator) @@ -100,14 +100,14 @@ def test_deformation_step_is_the_closed_form(transport, stress_history, integrat def test_linear_step_loses_the_conformation(): """The defect the deformation step removes, measured against its closed form.""" - c_min, _ = _extension_step("semi_lagrangian", "linear", "stress") + c_min, _ = _extension_step("backward_nodes", "linear", "stress") assert abs(c_min - (1 - 2 * DT * RATE + DT) / (1 + DT)) < 1.0e-6, c_min def _shear_startup(transport, integrator, steps=10, dt=0.1): uw.reset_default_model() mesh = uw.meshing.StructuredQuadBox(elementRes=(16, 8), minCoords=(-1.0, -0.5), maxCoords=(1.0, 0.5)) - tag = f"s{transport[:3]}{integrator}" + tag = f"s{"".join(w[0] for w in transport.split("_"))}{integrator}" v = uw.discretisation.MeshVariable(f"U_{tag}", mesh, 2, degree=2) p = uw.discretisation.MeshVariable(f"P_{tag}", mesh, 1, degree=1) stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) @@ -139,7 +139,7 @@ def _shear_startup(transport, integrator, steps=10, dt=0.1): @pytest.mark.parametrize("integrator", ["bdf", "etd"]) -@pytest.mark.parametrize("transport", ["semi_lagrangian", "integration_point", "forward", "eulerian"]) +@pytest.mark.parametrize("transport", ["backward_nodes", "backward_integration_points", "forward_integration_points", "eulerian"]) def test_log_conformation_history_follows_the_recurrence(transport, integrator): """Uniform stress, so transport is a no-op: this checks the encoding, the decoding and the step together, against the discrete recurrence.""" diff --git a/tests/test_1102_forward_nodes_rotating_gaussian.py b/tests/test_1102_forward_nodes_rotating_gaussian.py index fe2320679..9a990ba93 100644 --- a/tests/test_1102_forward_nodes_rotating_gaussian.py +++ b/tests/test_1102_forward_nodes_rotating_gaussian.py @@ -3,7 +3,7 @@ Launched from the field's own nodes and from a lattice inside every element (the field's interpolant is a polynomial there, so both are known exactly), carried one step forward, fitted per cell at the field's degree and read back at -the nodes. Used as the DuDt of the SLCN advection-diffusion solver, half a +the nodes. Selected by transport="forward_nodes" on the SLCN advection-diffusion solver, half a revolution, kappa 0.01, dt 0.02, P2 temperature, against the test_1101 fixture: the backward nodal history gives 2.52e-2 on the disc and 6.43e-2 on the box (where the flow crosses all four walls). @@ -25,8 +25,8 @@ def _run(mesh, walls): T = uw.discretisation.MeshVariable("T", mesh, 1, degree=2) T.array[:, 0, 0] = uw.function.evaluate(sol.at(0.0), T.coords).reshape(-1) V = sympy.Matrix([[-y, x]]) - duDt = uw.systems.ddt.ForwardNodesSemiLagrangian(mesh, T.sym, V, vtype=uw.VarType.SCALAR, degree=T.degree) - adv = uw.systems.AdvDiffusionSLCN(mesh, u_Field=T, V_fn=V, DuDt=duDt, order=1) + adv = uw.systems.AdvDiffusionSLCN(mesh, u_Field=T, V_fn=V, order=1, transport="forward_nodes") + assert type(adv.DuDt).__name__ == "ForwardNodesSemiLagrangian" adv.constitutive_model = uw.constitutive_models.DiffusionModel adv.constitutive_model.Parameters.diffusivity = KAPPA for wall in walls: From b20c992e3cee8ed8de8147ca8450ed952ff9aed7 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sat, 26 Sep 2026 23:28:04 -0700 Subject: [PATCH 03/21] Every stress and advection history gives the serial answer in parallel; forward from nodes carries a stress Forward from nodes as a stress history: the stress is committed by L2 projection onto the continuous store and launched from that field. It is offered by stress_transport="forward_nodes". The per-cell fits are projected onto the store (a shared node weighs every cell), each cell's fit uses only its own arrivals (a linear fit, their mean, or the launched field when too few), the interior lattice is two degrees up, and an arrival on a shared face is fitted in every cell that contains it. In parallel a seam node is launched by its owner, and arrivals that left a rank or sit on a face are offered to every rank. Partition dependence removed from the existing histories: - a field that is a mesh variable (T.sym as well as T) is recorded into the nodal and integration-point histories by copying its nodal data, in serial as in parallel, instead of evaluating it at nudged nodes; - the nodal trace starts from the node itself: the 0.1% nudge toward the closest cell's centroid depended on which cells a rank holds and gave a no-slip wall node a velocity; - every history projection is solved to 1e-10; - global_evaluate: a point that the migration stranded but that a rank's cell contains is evaluated there with the FE interpolant, not by the nearest-centroid rbf extrapolation (3e-2 at a steep stress). tests/parallel/test_1066: six stress and four advection histories equal serial to 1e-6 at np >= 3 (verified np 3, 4, 6); the particle history is a strict xfail at np >= 4 (1.2e-5, cause open). test_1062 tightened to 1e-6. Underworld development team with AI support from Claude Code Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo --- docs/developer/subsystems/stress-transport.md | 29 +- src/underworld3/function/_function.pyx | 33 +- src/underworld3/swarm.py | 4 + src/underworld3/systems/ddt.py | 496 ++++++++++-------- src/underworld3/systems/solvers.py | 36 +- .../utilities/cell_polynomial_projection.py | 80 ++- .../test_1062_forward_stress_history_mpi.py | 10 +- .../test_1066_transport_schemes_mpi.py | 78 +++ tests/test_1059_stress_transport.py | 10 +- ...st_1102_forward_nodes_rotating_gaussian.py | 4 +- 10 files changed, 504 insertions(+), 276 deletions(-) create mode 100644 tests/parallel/test_1066_transport_schemes_mpi.py diff --git a/docs/developer/subsystems/stress-transport.md b/docs/developer/subsystems/stress-transport.md index f5652da83..543b8a96c 100644 --- a/docs/developer/subsystems/stress-transport.md +++ b/docs/developer/subsystems/stress-transport.md @@ -17,15 +17,13 @@ stokes.constitutive_model.Parameters.solvent_viscosity = eta_s # Oldroyd-B; o stokes.constitutive_model.Parameters.dt_elastic = dt ``` -## The five histories +## The six histories The four semi-Lagrangian names are `_`, the arguments of `uw.systems.ddt.SemiLagrangian(..., trace=, launch=)`: a backward trace follows the characteristic back from each storage point and samples the old stress at the foot; a forward trace carries the old stress from where it is known and fits the arrivals in -each cell. The fourth combination, forward from nodes, carries a field known at its -nodes (a temperature, say); a stress is formed at the integration points, so it is not -a stress history. The former names `semi_lagrangian`, `integration_point` and `forward` +each cell. The former names `semi_lagrangian`, `integration_point` and `forward` are accepted with a warning. | `stress_transport` | storage | carried by | stable at | fails by | @@ -33,6 +31,7 @@ are accepted with a warning. | `backward_nodes` (the default) | continuous P1 at the vertices | vertex trace-back, interpolation at the foot | any Courant number | excess stress in the first cells off a no-slip wall; on the confined cylinder that excess loses the conformation and the solve hangs | | `backward_integration_points` | continuous P1 store, sampled at the quadrature points | trace-back of every quadrature point | Courant near one, or below one with store smoothing | a cell-scale mode of the stress that grows below Courant one when the solvent viscosity is small | | `forward_integration_points` | discontinuous P1 per cell, fitted from the arrivals | fixed launch set of interior points (the integration points), one forward trajectory a step; the flux is read back at the launch points through a continuous P1 projection; an inflow cell's uncovered share is filled with the inflow value | the cylinder walls at dt 0.04; below Courant one with `flux_smoothing` at c = 0.023 (Waters-King 1/16, dt 0.0125: 0.9543 at t 1 and 0.5185 at t 6.5, against nodal 0.9622 and 0.5171) | the same cell-scale mode as the integration-point history without that smoothing (diverges at t 2.4 there); first order only; does not cross a periodic seam or follow a moving mesh | +| `forward_nodes` | continuous, the history's degree, at its nodes | the stress projected onto that store, launched from its nodes and from a lattice inside each element, one forward trajectory a step; a per-cell fit at the history's degree read back at the nodes | measured on transport alone (rotating diffusing Gaussian, P2: 1.78e-2 against 2.52e-2 for `backward_nodes` over half a turn) | not yet measured on a stress benchmark; first order only; does not follow a moving mesh | | `lagrangian` (particles) | a swarm the solver owns and advects, one value per particle, read through a discontinuous cells proxy | the material points themselves: the constitutive flux is evaluated at the particles each step and never projected back to the mesh; a particle that entered through an inflow takes the inflow value | any Courant number; no numerical diffusion of the history | the cost and bookkeeping of a swarm, and a proxy that needs its cells kept populated (population control refills them); the conformation check does not read a per-point tensor from it | | `eulerian` (SUPG grid) | continuous P1 | assembled transport equation with streamline upwinding | with DEVSS | without DEVSS the velocity block loses its preconditioner as the stress grows | @@ -69,6 +68,28 @@ where each flavour receives it, and anything a flavour writes from `evaluate` model with the timestep in kyr and as the same problem in plain numbers, from a moving start, at orders 1 and 2: the stores agree to solver precision. + +## In parallel + +Every history except the particle one gives the serial answer on any number of +ranks: `tests/parallel/test_1066` holds the six stress histories on a +turned-over Maxwell box and the four semi-Lagrangian value histories on a +rotating Gaussian to 1e-6 of their serial values at np 3, 4 and 6. Four things +make that so, and a new history has to respect them: + +- a field that is itself a mesh variable is recorded into a history by copying + its nodal values, never by evaluating it at its nodes; +- a trace starts from the node or point itself: a nudge toward "the nearest + cell's" centroid depends on which cells a rank holds; +- every history projection is solved to 1e-10, since its result is the carried + field and an iterative error is partition-dependent; +- a point is never given to one of two cells by a tie-break: a forward arrival + on a shared face is fitted in every cell that contains it, and a point the + parallel evaluator strands is evaluated by the rank whose cell contains it. + +The particle history differs from serial by about 1e-5 at np 4 and 6 (np 3 +matches); the cause is open. + ## The timestep is set by the wall strain rate, not the far-field Courant number The objective-rate source $L\sigma^* + \sigma^* L^T$ acts on the carried stress diff --git a/src/underworld3/function/_function.pyx b/src/underworld3/function/_function.pyx index 96540e25f..0c55057dc 100644 --- a/src/underworld3/function/_function.pyx +++ b/src/underworld3/function/_function.pyx @@ -572,9 +572,10 @@ def global_evaluate_nd( expr, # rank whose nearest cell is globally closest, and Allreduce(SUM of # the winner-only value/flag) scatters that rank's extrapolation back. # - # A point some rank actually contains (distance ~ 0) naturally wins, so - # only genuinely-stranded points are corrected. Cost is O(boundary points) - # — no dense global tree, no exhaustive search. + # A point some rank's cell contains is then evaluated by that rank with the + # FE interpolant, not the rbf extrapolation (see the containment round + # below). Cost is O(stranded points) — no dense global tree, no + # exhaustive search. # # DEADLOCK SAFETY — read before editing. Every collective here (allgather, # Allreduce) runs unconditionally on the IDENTICAL global set on every @@ -650,6 +651,32 @@ def global_evaluate_nd( expr, best_flag = np.empty(n_ext_total, dtype=np.int32) comm.Allreduce([contrib_flag, MPI.INT], [best_flag, MPI.INT], op=MPI.SUM) + # A stranded point that a rank's cell CONTAINS is not out of the + # domain: the migration's claim (points_in_domain) is looser than + # cell containment, so a point a hair from a partition seam can be + # claimed by the neighbour, found in none of its cells and stranded. + # The rank that contains it evaluates it with the FE interpolant, + # exactly as serial evaluate() would (the rbf value above is only + # for points no rank contains). Every rank calls evaluate_nd, on + # the points it contains -- possibly none -- as the first pass does + # on the points it received, so the collectives stay in lockstep. + contains = np.asarray(mesh._robust_owning_cells(all_ext)) >= 0 + my_owner = np.where(contains, comm.rank, comm.size).astype(np.int32) + owner = np.empty(n_ext_total, dtype=np.int32) + comm.Allreduce([my_owner, MPI.INT], [owner, MPI.INT], op=MPI.MIN) + mine = owner == comm.rank + fe_vals, _fe_flag = evaluate_nd( + expr, np.ascontiguousarray(all_ext[mine]), rbf=rbf, evalf=evalf, + verbose=False, simplify=simplify, check_extrapolated=True,) + contrib_fe = np.zeros((n_ext_total,) + expr_shape, dtype=np.float64) + if mine.any(): + contrib_fe[mine] = np.asarray(fe_vals, dtype=np.float64).reshape((-1,) + expr_shape) + fe_val = np.empty_like(contrib_fe) + comm.Allreduce([contrib_fe, MPI.DOUBLE], [fe_val, MPI.DOUBLE], op=MPI.SUM) + contained = owner < comm.size + best_val[contained] = fe_val[contained] + best_flag[contained] = 0 + # Scatter this rank's segment of the global set back to its points. offset = int(counts[:comm.rank].sum()) seg = slice(offset, offset + ext_coords.shape[0]) diff --git a/src/underworld3/swarm.py b/src/underworld3/swarm.py index 48c2d0e2b..34043efc3 100644 --- a/src/underworld3/swarm.py +++ b/src/underworld3/swarm.py @@ -5923,6 +5923,10 @@ def estimate_dt(self, V_fn): import math import numpy as np + # TODO(BUG): with evalf=True evaluate() flags EVERY point as extrapolated, so + # at np > 1 this raises the 'not located on this rank' warning for particles + # that are all local (seen on the Lagrangian stress history, 2026-09-27). + # See planning file: underworld.md (Bugs section, 2026-09-27) vel = uw.function.evaluate(V_fn, self._particle_coordinates.data, evalf=True) # If vel is unit-aware (UnitAwareArray), nondimensionalise it to get diff --git a/src/underworld3/systems/ddt.py b/src/underworld3/systems/ddt.py index 96d942ba2..d7a034f6a 100644 --- a/src/underworld3/systems/ddt.py +++ b/src/underworld3/systems/ddt.py @@ -223,6 +223,40 @@ def _history_units(psi_fn, units=None): return units if units is not None else uw.get_units(psi_fn) +# A history projection's result is the carried field itself, so it is solved +# to convergence: a mass-matrix solve left at a projection's default tolerance +# (1e-4) carries an error of that size into every step, and the error depends +# on the partition. Converging it costs a few more iterations. +_HISTORY_PROJECTION_TOLERANCE = 1.0e-10 + + +def _tracked_field(mesh, psi_fn, store): + """The mesh variable whose values ``psi_fn`` is, when it is laid out like + ``store`` (same degree, continuity and components), else ``None``. + + Recording such a field into a history is a copy of its nodal values: + exact, and the same on any partition. Evaluating it at the nodes instead + locates each node in a cell, and a node on a partition seam is located in + a different cell on each rank. ``psi_fn`` is the variable itself or its + symbol (``T.sym``, or ``T.sym[0]`` for a one-component field). + """ + if isinstance(psi_fn, uw.discretisation.MeshVariable): + field = psi_fn + else: + hit = uw.discretisation.meshVariable_lookup_by_symbol(mesh, psi_fn) + if hit is None and isinstance(psi_fn, sympy.MatrixBase) and psi_fn.shape == (1, 1): + hit = uw.discretisation.meshVariable_lookup_by_symbol(mesh, psi_fn[0, 0]) + if hit is None: + return None + field, component = hit + if component != -1 and field.num_components != 1: + return None + if (field.degree, field.continuous, field.num_components) != ( + store.degree, store.continuous, store.num_components): + return None + return field + + def _write_evaluated(var, values): """Write an evaluation of a store's quantity into the store. @@ -1128,6 +1162,33 @@ def _nondim_timestep(self, dt): reduced = _as_float(dt) return dt if reduced is None else reduced + def _project_nodally(self, expr, name="flux", smoothing=0.0, verbose=False): + """L2 projection of the stored components of ``expr`` onto a field of the + history's degree and continuity; returns that (1, ncomponents) field. + A flux holds gradients, which are discontinuous across elements, so it + is projected rather than read at nodes. Each ``name`` keeps its own + projection, so a history that projects two expressions does not + recompile one projection back and forth.""" + if not hasattr(self, "_nodal_projections"): + self._nodal_projections = {} + expr = sympy.Matrix(expr) + columns = _storage_components(self.vtype, expr.shape) + if name not in self._nodal_projections: + target = uw.discretisation.MeshVariable( + f"{name}_nodal_{self.instance_number}", self.mesh, (1, len(columns)), + vtype=uw.VarType.MATRIX, degree=self.degree, + continuous=self.continuous, + varsymbol=rf"{{{name}^{{\mathrm{{nodal}}}}_{{{self.instance_number}}}}}") + projection = uw.systems.solvers.SNES_MultiComponent_Projection( + self.mesh, u_Field=target, n_components=len(columns), verbose=self.verbose) + projection.tolerance = _HISTORY_PROJECTION_TOLERANCE + self._nodal_projections[name] = (target, projection) + target, projection = self._nodal_projections[name] + projection.uw_function = sympy.Matrix([[expr[i, j] for (i, j) in columns]]) + projection.smoothing = smoothing + projection.solve(verbose=verbose) + return target + def _write_inflow(self, var, coords, rows): """Overwrite ``rows`` of ``var`` with :attr:`inflow_value` evaluated at ``coords`` (the positions of ALL the points, so that the read, which @@ -1699,6 +1760,7 @@ def _setup_projections(self): verbose=False, ) self._psi_star_use_multicomponent = True + self._psi_star_projection_solver.tolerance = _HISTORY_PROJECTION_TOLERANCE self._psi_star_projection_solver.uw_function = self._build_projection_source( self.psi_fn) @@ -2487,15 +2549,13 @@ def _segment_exprs(self, seg): half = sympy.Rational(1, 2) return self.level_expr(k - 1), (self.level_expr(k - 1) + self.level_expr(k)) * half - def departure_points(self, key, X0, segments, evalf=False, X_eval=None, + def departure_points(self, key, X0, segments, evalf=False, clamp_final=True, subtract_v_mesh=False, v_mesh_var=None): r"""Trace ``X0`` back through ``segments`` (RK2 midpoint each): ``x_mid = x - dt/2 v_start(x)``, ``x_dep = x - dt v_mid(x_mid)``. - ``key`` names the launch node set (a variable's ``_basis_key`` plus - a tag for the nudge); ``X_eval`` are the points where the first - segment's start velocity is evaluated when they differ from ``X0`` - (the centroid-nudged nodes of the nodal history). Midpoints are + ``key`` names the launch point set (a variable's ``_basis_key`` plus + a tag). Midpoints are clamped to the domain; the last point is clamped unless ``clamp_final`` is False (old-frame reach-back). """ @@ -2513,8 +2573,7 @@ def departure_points(self, key, X0, segments, evalf=False, X_eval=None, continue kind, k, dt = segments[j] v_start, v_mid = self._segment_exprs(segments[j]) - X_start = X_eval if (j == 0 and X_eval is not None) else X - v0 = self.velocity_at(v_start, X_start, use_global=j > 0, evalf=evalf, + v0 = self.velocity_at(v_start, X, use_global=j > 0, evalf=evalf, subtract_v_mesh=subtract_v_mesh, v_mesh_var=v_mesh_var) Xm = X - v0 * (0.5 * dt) if clamp is not None: @@ -2969,6 +3028,7 @@ def __init__( verbose=False, ) self._psi_star_use_multicomponent = True + self._psi_star_projection_solver.tolerance = _HISTORY_PROJECTION_TOLERANCE # We should find a way to add natural bcs here # (self.Unknowns.u carried as a symbol from solver to solver) @@ -3283,40 +3343,15 @@ def _consume_ale_pulse(self): """ self._pending_v_mesh_disp = None - def _record_psi_star_from_field_data(self): - """Parallel-safe 'record current field into psi_star[0]'. - - The default record step evaluates ``psi_fn`` at its own node - coordinates, which under MPI mis-locates on-vertex points at a - process seam (first-pass ``get_closest_cells`` + FE extrapolation), - seeding a spurious history value. When ``psi_fn`` is a single - mesh-variable component living on this mesh with the same nodal - layout as ``psi_star[0]``, "evaluate at own nodes" is exactly that - variable's nodal data, so we copy it directly — no point location. - - Returns an array shaped like ``psi_star[0].array`` for that case, or - ``None`` (caller falls back to ``evaluate``) for non-scalar or - expression ``psi_fn`` (e.g. a flux with derivatives). - """ - try: - comps = list(self.psi_fn) # sympy Matrix, row-major - if len(comps) != 1: # scoped to scalar fields - return None - hit = uw.discretisation.meshVariable_lookup_by_symbol( - self.mesh, comps[0]) - if hit is None: - return None - var, comp = hit - vflat = np.asarray(var.array) - vflat = vflat.reshape(vflat.shape[0], -1) - out = np.array(np.asarray(self.psi_star[0].array)) - oflat = out.reshape(out.shape[0], -1) - if vflat.shape[0] != oflat.shape[0] or oflat.shape[1] != 1: - return None - oflat[:, 0] = vflat[:, comp] - return out - except Exception: - return None + def _copy_tracked_field(self): + """Record the current field into ``psi_star[0]`` by copying its nodal + values, when ``psi_fn`` is a mesh variable laid out like the store + (see :func:`_tracked_field`). Returns whether it did.""" + field = _tracked_field(self.mesh, self.psi_fn, self.psi_star[0]) + if field is None: + return False + self.psi_star[0].data[...] = field.data[...] + return True def _midtime_velocity_expr(self): r"""Velocity at :math:`t^{n+1/2}` for the mid-point stage of the @@ -3337,14 +3372,6 @@ def _record_velocity_history(self): if getattr(self, "_owns_characteristics", True): self.characteristics.finish_step() - def _centroid_shifted_var_coords(self, var): - """ND node coordinates of ``var`` nudged 0.1 % toward their cell - centroids (see :meth:`_centroid_shifted_node_coords`).""" - coords = np.asarray(var.coords_nd) - cellid = self.mesh.get_closest_cells(coords).reshape(-1) - cent = np.asarray(self.mesh._centroids)[cellid] - return 0.999 * coords + 0.001 * cent - def _velocity_nd_at( self, coords, @@ -3463,83 +3490,28 @@ def carried_tensors(self, level: int = 0): points = _to_nondim_ndarray(history.coords).reshape(-1, self.mesh.cdim) return values, points - def _centroid_shifted_node_coords(self): - r"""ND node coordinates of ``psi_star[0]``, nudged toward cell centroids. - - Point-location and FE interpolation are ambiguous exactly on - element edges/vertices (worst on quad meshes at the domain - boundary), so the sample points are moved 0.1 % of the way toward - the centroid of their owning cell: far enough to make cell - ownership unambiguous, close enough not to bias the sampled - values. Coordinates are plain non-dimensional arrays — never raw - ``.magnitude``, which would be dimensional metres (see - ``_to_nondim_ndarray`` and issue #267). - """ - psi_star_0_coords_nd = _to_nondim_ndarray(self.psi_star[0].coords) - - cellid = self.mesh.get_closest_cells( - psi_star_0_coords_nd, - ) - centroid_coords = self.mesh._centroids[cellid] - - shift = 0.001 - return (1.0 - shift) * psi_star_0_coords_nd[:, :] + shift * centroid_coords[ - :, : - ] - def _record_current_field_into_history( - self, node_coords_nd, evalf, verbose, oldframe_active + self, node_coords_nd, evalf, verbose ): r"""Record the current value of :math:`\psi` into ``psi_star[0]``. Three routes, in order of preference: - 1. direct nodal copy of the tracked field's data (parallel, or - old-frame reach-back); + 1. direct nodal copy of the tracked field's data, when ``psi_fn`` + is a mesh variable laid out like the store (:func:`_tracked_field`); 2. pointwise evaluation of ``psi_fn`` at the centroid-shifted - node coordinates (the validated serial path); + node coordinates; 3. an L2 projection for expressions that ``evaluate`` cannot handle (e.g. the NS viscous flux, which contains derivatives). """ try: - # Use shifted ND coords to avoid quad mesh boundary issues - # node_coords_nd is slightly shifted toward cell centroids - # evaluate() treats plain numpy as ND [0-1] coordinates. - # - # PARALLEL band-aid (parallel-singular-corruption, 2026-05): - # this "record current field into psi_star" step samples psi_fn - # at its OWN node coords. On-vertex sampling + first-pass - # get_closest_cells mis-locates at a process seam under MPI, - # recording a spurious history value that the implicit solve - # then propagates (the seam spike in adaptive advection- - # diffusion). When psi_fn is a single mesh-variable component on - # this mesh (the SLCN adv-diff case), "evaluate at own nodes" == - # the field's nodal data, so under MPI copy it directly (exact, - # no point location). Serial keeps the validated shifted- - # evaluate path bit-identically; non-scalar / expression psi_fn - # falls back to evaluate(). Proper fix (remap-on-adapt / ALE) - # tracked separately. - # Old-frame: record the history by a DIRECT nodal carry - # of the field rather than re-evaluating psi_fn at the - # (centroid-shifted) nodes of the DEFORMED mesh. The - # re-evaluate injects boundary-layer interpolation error - # that grows with mesh distortion and then rides the - # old-geometry sample below — the exact value we want is - # the carried nodal value (cf. the lagged-clone "store - # primitives" principle). Reuses the parallel direct-copy - # path, which returns None for non-scalar / expression - # psi_fn (those fall back to evaluate). - _direct = (self._record_psi_star_from_field_data() - if (uw.mpi.size > 1 or oldframe_active) else None) - if _direct is not None: - eval_result = _direct - else: + if not self._copy_tracked_field(): eval_result = uw.function.evaluate( self.psi_fn, node_coords_nd, evalf=evalf, ) - _write_evaluated(self.psi_star[0], eval_result) + _write_evaluated(self.psi_star[0], eval_result) except Exception: # Fallback to projection solver for expressions that can't be directly evaluated @@ -3566,7 +3538,7 @@ def _record_current_field_into_history( self.psi_star[0].array[:, j, i] = vals def _trace_departure_points( - self, i, node_coords_nd, dt_for_calc, evalf, subtract_v_mesh, oldframe_active + self, i, dt_for_calc, evalf, subtract_v_mesh, oldframe_active ): r"""RK2 midpoint trace-back: departure points for history slot ``i``. @@ -3586,16 +3558,14 @@ def _trace_departure_points( any foot that falls outside the old mesh, matching the validated prototype, which omits this clamp). """ - # One RK2 segment from the true nodes, the start velocity taken at - # the centroid-nudged coordinates (node_coords_nd). Served from the - # shared trace: a second history on the same nodes, or an older slot - # of this one, reuses the departure points computed here. + # One RK2 segment from the nodes. Served from the shared trace: a + # second history on the same nodes, or an older slot of this one, + # reuses the departure points computed here. return self.characteristics.departure_points( - (_basis_key_of(self.psi_star[i]), "nudged"), + (_basis_key_of(self.psi_star[i]), "nodes"), np.asarray(self.psi_star[i].coords_nd), (("first", 0, dt_for_calc),), evalf=evalf, - X_eval=node_coords_nd, clamp_final=not oldframe_active, subtract_v_mesh=subtract_v_mesh, v_mesh_var=getattr(self, "_v_mesh_var", None), @@ -3755,11 +3725,15 @@ def update_pre_solve( # When store_result=False (e.g. VE stress history), skip this — # psi_star[0] already contains the projected actual stress from # the previous solve and we want to advect *that*, not the flux. - node_coords_nd = self._centroid_shifted_node_coords() + # The nodes themselves: a node on a no-slip wall has zero velocity and + # departs from where it is. (A 0.1% nudge toward "the closest cell's" + # centroid gave it a velocity, and on a partition seam a different + # nudge on each rank.) + node_coords_nd = np.asarray(self.psi_star[0].coords_nd) if store_result: self._record_current_field_into_history( - node_coords_nd, evalf, verbose, _oldframe_active + node_coords_nd, evalf, verbose ) # 3. Trace the characteristics back and sample each history slot @@ -3793,7 +3767,7 @@ def update_pre_solve( _ale_this_iter = _ale_active and not (store_result and i == 0) end_pt_coords = self._trace_departure_points( - i, node_coords_nd, dt_for_calc, evalf, + i, dt_for_calc, evalf, _ale_this_iter, _oldframe_active, ) self._sample_history_at_departure( @@ -4696,6 +4670,43 @@ def update_post_solve( +def _hand_arrivals_to_owners(X, columns, locate): + """Give every arrival to the rank whose cell it landed in. + + ``locate`` returns the owning local cell of each point, -1 where the point + is in none of this rank's cells. Only the points that left their rank + travel: each rank offers its leavers to everyone and keeps the offered + points that land in its own cells (at Courant one that is the seam layer, + not the whole set). A point no rank takes has left the domain and is + dropped. The locators are local; the exchange is the only collective and + every rank makes it, a rank with no cells contributing and taking nothing + (comm.allgather rather than gather_data: the rows are vectors, and + gather_data flattens). + + Returns the kept points, their ``columns`` (arrays with a row per point), + their cells, and how many arrived from another rank. + """ + def owner(P): + return (np.asarray(locate(P), dtype=np.int64).reshape(-1) if P.shape[0] + else np.zeros(0, dtype=np.int64)) + + X = np.asarray(X) + columns = [np.asarray(c) for c in columns] + own = owner(X) + stay = own >= 0 + if uw.mpi.size == 1: + return X[stay], [c[stay] for c in columns], own[stay], 0 + comm = uw.mpi.comm + offered = np.concatenate(comm.allgather(X[~stay]), axis=0) + offered_columns = [np.concatenate(comm.allgather(c[~stay]), axis=0) for c in columns] + taken = owner(offered) + take = taken >= 0 + return (np.concatenate([X[stay], offered[take]], axis=0), + [np.concatenate([c[stay], o[take]], axis=0) for c, o in zip(columns, offered_columns)], + np.concatenate([own[stay], taken[take]]), + int(take.sum())) + + def _psi_shape_for(vtype, cdim): """The symbolic shape a history of ``vtype`` must have.""" if vtype == uw.VarType.SCALAR: @@ -4784,9 +4795,6 @@ class BackwardIntegrationPointsSemiLagrangian(_DDtBase): #: there when one is set (#745); without one they sample the edge. applies_inflow_value = True - _commit_projection = None - _commit_flat = None - def commit_flux_to_history(self, flux, verbose=False): """Project the new flux into the nodal snapshot and read it at the points, then shift both ladders. @@ -4806,22 +4814,8 @@ def commit_flux_to_history(self, flux, verbose=False): explicit and needs no snapshot substitution. """ history = self.psi_star[0] - flux = sympy.Matrix(flux) - columns = _storage_components(self.vtype, flux.shape) - - if self._commit_projection is None: - self._commit_flat = uw.discretisation.MeshVariable( - f"flux_nodal_{self.instance_number}", self.mesh, (1, len(columns)), - vtype=uw.VarType.MATRIX, degree=self.degree, - continuous=self.continuous, - varsymbol=rf"{{F^{{\mathrm{{nodal}}}}_{{{self.instance_number}}}}}") - self._commit_projection = uw.systems.solvers.SNES_MultiComponent_Projection( - self.mesh, u_Field=self._commit_flat, n_components=len(columns), - verbose=self.verbose) - self._commit_projection.uw_function = sympy.Matrix( - [[flux[i, j] for (i, j) in columns]]) - self._commit_projection.smoothing = self._store_smoothing_alpha() - self._commit_projection.solve(verbose=verbose) + columns = _storage_components(self.vtype, sympy.Matrix(flux).shape) + nodal_flux = self._project_nodally(flux, smoothing=self._store_smoothing_alpha(), verbose=verbose) # Oldest first, so each level reads the one above before it is written. for level in range(self.order - 1, 0, -1): @@ -4830,10 +4824,10 @@ def commit_flux_to_history(self, flux, verbose=False): points = np.asarray(history.integration_points).reshape(-1, self.mesh.cdim) for column in range(len(columns)): - nodal = self._commit_flat.data[:, column] + nodal = nodal_flux.data[:, column] self.psi_snap[0].data[:, column] = np.asarray(nodal).reshape(-1) history.data[:, column] = np.asarray(uw.function.evaluate( - self._commit_flat.sym[0, column], points)).reshape(-1) + nodal_flux.sym[0, column], points)).reshape(-1) self._history_committed = True @@ -5180,25 +5174,14 @@ def _object_viewer(self): display(Latex(rf"$\quad$History steps = {self.order} (at the integration points)")) # ------------------------------------------------------------------ - def _nudged_node_coords(self, var): - """ND node coordinates of ``var`` moved 0.1 % toward their cell - centroids so boundary nodes locate unambiguously (see - :meth:`BackwardNodesSemiLagrangian._centroid_shifted_node_coords`).""" - coords = np.asarray(var.coords_nd) - cellid = self.mesh.get_closest_cells(coords).reshape(-1) - cent = np.asarray(self.mesh._centroids)[cellid] - return 0.999 * coords + 0.001 * cent - def _record_current(self): """Snapshot slot 0 <- the current solution and velocity.""" ps = self.psi_snap[0] - if self._psi_meshVar is not None and ( - self._psi_meshVar.degree == ps.degree - and self._psi_meshVar.continuous == ps.continuous - ): - ps.data[...] = self._psi_meshVar.data[...] + field = _tracked_field(self.mesh, self._psi_meshVar or self.psi_fn, ps) + if field is not None: + ps.data[...] = field.data[...] else: - self._write_components(ps, self.psi_fn, self._nudged_node_coords(ps)) + self._write_components(ps, self.psi_fn, np.asarray(ps.coords_nd)) def _write_components(self, var, expr, coords, evaluate=None, **kwargs): """Evaluate ``expr`` at ``coords`` and store it component by component. @@ -5281,6 +5264,7 @@ def update_forcing_history(self, forcing_fn=None, evalf=False, verbose=False): self._forcing_projection = uw.systems.solvers.SNES_MultiComponent_Projection( self.mesh, u_Field=self._forcing_flat, n_components=len(columns), verbose=self.verbose) + self._forcing_projection.tolerance = _HISTORY_PROJECTION_TOLERANCE self._forcing_projection.uw_function = sympy.Matrix( [[forcing[i, j] for (i, j) in columns]]) self._forcing_projection.smoothing = 0.0 @@ -5595,6 +5579,7 @@ def _evaluate_at_launch(self, expr): if self._flux_projection is None: self._flux_projection = uw.systems.solvers.SNES_MultiComponent_Projection( self.mesh, u_Field=self._flux_var, n_components=self.num_components) + self._flux_projection.tolerance = _HISTORY_PROJECTION_TOLERANCE self._flux_projection.uw_function = sympy.Matrix([[expr[i, j] for (i, j) in self._components]]) self._flux_projection.smoothing = self.flux_smoothing self._flux_projection.solve() @@ -5636,38 +5621,15 @@ def _fit_arrivals(self, X, values, cell=None, inflow=None): ncell = self._cell_measure.size w = self._launch_weights if cell is None: - if uw.mpi.size > 1: - # Only the points that left this rank's partition travel: each rank - # offers its leavers to everyone and keeps the offered points that - # land in its own cells. At Courant one that is the seam layer, not - # the whole set. A rank with no cells owns nothing and keeps nothing. - # Ownership is by strict containment (face tolerance zero), not the - # evaluation locator's slab: a point a hair across a seam face would - # otherwise be kept by the rank it left and fitted into the wrong - # cell. The locators are local; the exchange is the only collective - # and every rank makes it, a rank with no cells contributing and - # taking nothing. (comm.allgather rather than gather_data: the rows - # are vectors, and gather_data flattens.) - X, values = np.asarray(X), np.asarray(values) - own = np.asarray(mesh._get_closest_local_cells_internal(X, tol=0.0), dtype=int).reshape(-1) - stay = own >= 0 - comm = uw.mpi.comm - offered = np.concatenate(comm.allgather(X[~stay]), axis=0) - offered_values = np.concatenate(comm.allgather(values[~stay]), axis=0) - offered_w = np.concatenate(comm.allgather(w[~stay]), axis=0) - taken = np.asarray(mesh._get_closest_local_cells_internal(offered, tol=0.0), dtype=int).reshape(-1) - take = taken >= 0 - self._n_relocated = int(take.sum()) - X = np.concatenate([X[stay], offered[take]], axis=0) - values = np.concatenate([values[stay], offered_values[take]], axis=0) - w = np.concatenate([w[stay], offered_w[take]], axis=0) - cell = np.concatenate([own[stay], taken[take]]) - else: - # the same strict rule as the parallel path, so a partition does - # not change which cell a point on a face is fitted into - cell = np.asarray(mesh._get_closest_local_cells_internal(X, tol=0.0), dtype=int).reshape(-1) - inside = cell >= 0 - X, values, w, cell = X[inside], values[inside], w[inside], cell[inside] + # Ownership is by strict containment (face tolerance zero), not the + # evaluation locator's slab: a point a hair across a seam face would + # otherwise be kept by the rank it left and fitted into the wrong + # cell; serially the same rule, so a partition does not change which + # cell a point on a face is fitted into. + def strict(P): + return np.asarray(mesh._get_closest_local_cells_internal(P, tol=0.0), dtype=int).reshape(-1) + X, (values, w), cell, self._n_relocated = _hand_arrivals_to_owners( + X, (values, w), strict) centroid = np.asarray(mesh._centroids)[:, :d] h = np.sqrt(self._cell_measure) if d == 2 else np.cbrt(self._cell_measure) # centred on the cell and scaled by its size, so the constant is c0 and the @@ -5785,22 +5747,32 @@ class ForwardNodesSemiLagrangian(_DDtBase): A field is known exactly at its own nodes (they are its unknowns) and, since its interpolant is a polynomial inside each element, at any point of an element's interior. Each step launches the values at the nodes and at a - lattice inside every element (the points of the discontinuous basis one - degree up), carries each one step forward along the characteristic, fits + lattice inside every element (the points of the discontinuous basis two + degrees up), carries each one step forward along the characteristic, fits the arrivals in each cell to a polynomial of the field's own degree, and - reads that fit back at the nodes. The fit never reaches across an element - boundary, where the interpolant has a kink. A cell with too few arrivals, or - arrivals on a line, falls back to a linear fit over the nearest arrivals; a - cell nothing reached keeps the field it launched (see + projects the per-cell fits (L2) onto the continuous store. The fit never + reaches across an element boundary, where the interpolant has a kink; the + projection weighs every cell that shares a node. A cell with too few arrivals + for that takes a linear fit to its own arrivals, or their mean; a cell + nothing reached keeps the field it launched (see :class:`~underworld3.utilities.cell_polynomial_projection.CellPolynomialProjector`). Arrivals that leave the domain are dropped; a node the flow reached from outside takes :attr:`inflow_value` when one is set. - The integration-point counterpart is :class:`ForwardIntegrationPointsSemiLagrangian`, which - launches from the quadrature points, where a flux (a stress) is formed, and - is the forward scheme for one: this class carries a field and refuses a flux. - First order; serial (a node near a partition seam needs arrivals from the - other rank); a fixed mesh. + A flux (a viscoelastic stress) is committed by L2 projection onto the + continuous store, since it holds gradients that are discontinuous across + elements; the next step launches from that projected field, which is a + polynomial inside each element like any other. The integration-point + counterpart, :class:`ForwardIntegrationPointsSemiLagrangian`, launches a + flux from the quadrature points where it is formed. + + An arrival on a face or vertex shared by several cells (a node on a + no-slip wall arrives where it started) is fitted in every one of them. In + parallel a node shared by two ranks is launched once, by its owner, and an + arrival that crosses a partition seam, or lands on one, is handed to every + rank with a cell that contains it, so each cell is fitted from everything + that reached it and the result does not depend on the partition. First + order; a fixed mesh. Parameters ---------- @@ -5826,15 +5798,13 @@ def __init__(self, mesh, psi_fn, V_fn, vtype=VarType.SCALAR, degree=1, varsymbol raise NotImplementedError("ForwardNodesSemiLagrangian carries one level; order must be 1") if mesh.cdim != mesh.dim: raise NotImplementedError("ForwardNodesSemiLagrangian fits in the embedding coordinates; no manifolds") - if uw.mpi.size > 1: - raise NotImplementedError("ForwardNodesSemiLagrangian is serial: the fit at a node near a " - "partition seam needs the arrivals on the other rank") if _unsupported: warnings.warn(f"ForwardNodesSemiLagrangian ignores {sorted(_unsupported)}", stacklevel=2) self.vtype = vtype self.mesh = mesh self.degree = int(degree) self.continuous = True + self.verbose = False self.order = 1 self.theta = float(theta) self.V_fn = V_fn @@ -5855,8 +5825,18 @@ def __init__(self, mesh, psi_fn, V_fn, vtype=VarType.SCALAR, degree=1, varsymbol self._fit_var = uw.discretisation.MeshVariable( f"fit_fwn_{self.instance_number}", mesh, vtype=vtype, degree=self.degree, continuous=False) self._projector = CellPolynomialProjector(self._fit_var) - self._interior = np.asarray(mesh._get_coords_for_basis(self.degree + 1, continuous=False) + # two degrees up: enough arrivals that every cell of the rotating + # Gaussian fits at the field's degree (one degree up left ~8% of the + # cells to the linear fallback; three gains nothing) + self._interior = np.asarray(mesh._get_coords_for_basis(self.degree + 2, continuous=False) ).reshape(-1, mesh.cdim) + # A node on a partition seam is on both ranks and is launched once, by its + # owner: writing the rank into the store and reading it back after the + # ghost update leaves every copy holding its owner's rank. + self.psi_star[0].data[:, 0] = uw.mpi.rank + self._owned = np.asarray(self.psi_star[0].data[:, 0]) == uw.mpi.rank + self.psi_star[0].data[:, :] = 0.0 + self._n_relocated = 0 self._n_v = 2 self._init_coefficient_expressions(1, self.theta, with_exp=True) self._register_with_default_model() @@ -5893,26 +5873,73 @@ def _values_at(self, expr, X): np.asarray(_to_nondim_ndarray(uw.function.evaluate(expr[i, j], X))).reshape(-1) for (i, j) in self._components]) - def _reconstruct(self, arrivals, values, nodes): - """Fit the arrivals in each cell at the field's degree; the fit at the nodes. - A cell nothing reached keeps the field it launched.""" - inside = np.asarray(self.mesh.points_in_domain(arrivals), dtype=bool) - launched = self._values_at(self._psi_fn, np.asarray(self._fit_var.coords_nd)) - fit = self._projector.fit(arrivals[inside], values[inside], old=launched) - at_nodes = self._projector.interpolate(fit, nodes) - if np.isnan(at_nodes).any(): - raise RuntimeError("ForwardNodesSemiLagrangian: a node lies in no cell of the fit") - return at_nodes + _FACE_TOLERANCE = 1.0e-9 + + def _arrivals_by_cell(self, X, values): + """Every arrival with every cell that contains it, on the rank that owns + the cell: one row per (arrival, cell). + + An arrival strictly inside a cell of this rank belongs to that cell + alone. One on a face or vertex (a node on a no-slip wall arrives where + it started) belongs to every cell that shares it, and one in none of + this rank's cells has left the rank; both are offered to every rank, + which takes them into each of its own cells that contains them. No + tie-break between cells is made, so the assignment, and the fit, are the + same on any partition. + """ + pj = self._projector + tol = self._FACE_TOLERANCE + point, cell, lam = pj.containing_cells(X, tol) + strict = np.zeros(X.shape[0], dtype=bool) + strict[point[lam > tol]] = True + keep = strict[point] + offered_X, offered_v = X[~strict], values[~strict] + if uw.mpi.size > 1: + comm = uw.mpi.comm + parts_X = comm.allgather(offered_X) + parts_v = comm.allgather(offered_v) + offered_X = np.concatenate(parts_X, axis=0) + offered_v = np.concatenate(parts_v, axis=0) + mine_from = sum(p.shape[0] for p in parts_X[:uw.mpi.rank]) + n_mine = parts_X[uw.mpi.rank].shape[0] + else: + mine_from, n_mine = 0, offered_X.shape[0] + q, qcell, _ = pj.containing_cells(offered_X, tol) + own = (q >= mine_from) & (q < mine_from + n_mine) + self._n_relocated = int(np.unique(q[~own]).size) + return (np.concatenate([X[point[keep]], offered_X[q]], axis=0), + np.concatenate([values[point[keep]], offered_v[q]], axis=0), + np.concatenate([cell[keep], qcell])) + + def _reconstruct(self, arrivals, values, source): + """Fit the arrivals in each cell at the field's degree, and project the + per-cell fits onto the continuous store. A node is shared by several + cells whose fits differ slightly; the projection weighs them all, where + reading one cell's fit would depend on which cell (and in parallel which + rank) the node was located in. A cell nothing reached keeps the field it + launched (``source``).""" + arrivals, values, cells = self._arrivals_by_cell(arrivals, values) + launched = self._values_at(source, np.asarray(self._fit_var.coords_nd)) + self._fit_var.data[:, :] = self._projector.fit(arrivals, values, old=launched, + cell_local=True, cells=cells) + return np.array(self._project_nodally(self._fit_var.sym, name="fit").data) # ------------------------------------------------------------------ def initialise_history(self): - """Start from the current field at the nodes.""" + """Start from the current field at the nodes. A history already placed + by :meth:`commit_flux_to_history` is the start, and is kept.""" self.characteristics.initialise_levels(self._n_v) - self.psi_star[0].data[:, :] = self._values_at(self._psi_fn, self._nodes()) + if not self._history_committed: + self.psi_star[0].data[:, :] = self._values_at(self._psi_fn, self._nodes()) self._history_initialised = True - def update_pre_solve(self, dt, evalf=False, verbose=False, **_ignored): - """Carry the field forward one step from the nodes and rebuild it there.""" + def update_pre_solve(self, dt, evalf=False, verbose=False, store_result=True, **_ignored): + """Carry the field forward one step from the nodes and rebuild it there. + + ``store_result=False`` says the store already holds the values to launch + (a flux placed by :meth:`commit_flux_to_history`); otherwise the tracked + field is read first. + """ self._dt = dt = self._nondim_timestep(dt) if self._projector.mesh_version != self.mesh._mesh_version: raise NotImplementedError( @@ -5926,19 +5953,27 @@ def update_pre_solve(self, dt, evalf=False, verbose=False, **_ignored): if self._owns_characteristics: trace.begin_step(dt) nodes = self._nodes() - launch = np.vstack([nodes, self._interior]) - values = self._values_at(self._psi_fn, launch) + owned = nodes[self._owned] + launch = np.vstack([owned, self._interior]) + # the tracked field, or the committed store, which is a polynomial + # inside each element, so its interior values are exact + source = self._psi_fn if store_result else self.psi_star[0].sym + at_owned = (self._values_at(source, owned) if store_result + else np.asarray(self.psi_star[0].data)[self._owned]) + values = np.vstack([at_owned, self._values_at(source, self._interior)]) key = (_basis_key_of(self.psi_star[0]), "launch") arrivals = np.asarray(trace.departure_points(key, launch, (("first", 0, -float(dt)),), evalf=evalf, clamp_final=False)) - self.psi_star[0].data[:, :] = self._reconstruct(arrivals, values, nodes) - arrivals = arrivals[:nodes.shape[0]] + carried = self._reconstruct(arrivals, values, source) if self._inflow_value is not None: - # a node whose back-trace leaves the domain holds fluid that entered this step - back = 2.0 * nodes - arrivals - entered = ~np.asarray(self.mesh.points_in_domain(back), dtype=bool) - if entered.any(): - self._write_inflow(self.psi_star[0], nodes, entered) + # an owned node whose back-trace leaves the domain holds fluid that + # entered this step (a ghost copy takes its owner's value) + back = 2.0 * owned - arrivals[:owned.shape[0]] + entered = np.zeros(nodes.shape[0], dtype=bool) + entered[np.flatnonzero(self._owned)] = ~np.asarray(self.mesh.points_in_domain(back), dtype=bool) + inflow = self._values_at(self._inflow_record(), nodes) + carried[entered] = inflow[entered] + self.psi_star[0].data[:, :] = carried if self._owns_characteristics: trace.finish_step() @@ -5952,11 +5987,10 @@ def update_post_solve(self, dt, evalf=False, verbose=False, **_ignored): self._n_solves_completed += 1 def commit_flux_to_history(self, flux, verbose=False): - """Refused: a flux is formed at the integration points, not known at the nodes.""" - raise NotImplementedError( - "ForwardNodesSemiLagrangian carries a field known at its nodes; a flux (a stress) " - "is formed at the integration points, so carry it with " - "ForwardIntegrationPointsSemiLagrangian") + """Project the new flux onto the store, where the next step launches it.""" + projected = self._project_nodally(flux, verbose=verbose) + self.psi_star[0].data[:, :] = np.asarray(projected.data) + self._history_committed = True _SEMI_LAGRANGIAN_SCHEMES = { diff --git a/src/underworld3/systems/solvers.py b/src/underworld3/systems/solvers.py index d8b806186..507ae9412 100644 --- a/src/underworld3/systems/solvers.py +++ b/src/underworld3/systems/solvers.py @@ -462,16 +462,11 @@ def _invalidate_solution_cache(u): from .ddt import Symbolic as Symbolic_DDt # The semi-Lagrangian schemes a solver can build its history with, named -# "_" after the arguments of ddt.SemiLagrangian. A stress is -# formed at the integration points, so it is not carried forward from nodes. +# "_" after the arguments of ddt.SemiLagrangian. _SEMI_LAGRANGIAN_TRANSPORTS = ( "backward_nodes", "backward_integration_points", "forward_integration_points", "forward_nodes", ) -_STRESS_TRANSPORTS = ( - "backward_nodes", "backward_integration_points", "forward_integration_points", - "lagrangian", "eulerian", -) _RENAMED_TRANSPORTS = { "semi_lagrangian": "backward_nodes", "integration_point": "backward_integration_points", @@ -1565,9 +1560,10 @@ def set_jacobian_F1_source(self, F1_source, linesearch="cp"): def stress_transport(self) -> str: """How a viscoelastic stress history is carried: ``"backward_nodes"`` (default), ``"backward_integration_points"``, - ``"forward_integration_points"``, ``"lagrangian"`` or ``"eulerian"``. + ``"forward_integration_points"``, ``"forward_nodes"``, ``"lagrangian"`` + or ``"eulerian"``. - The first three are semi-Lagrangian schemes of + The first four are the semi-Lagrangian schemes of :func:`~underworld3.systems.ddt.SemiLagrangian`, named by the direction of the trace and the points the history is held at. A backward trace follows the characteristic back from each storage point and samples the @@ -1583,9 +1579,9 @@ def stress_transport(self) -> str: through a continuous P1 projection; it holds the Maxwell start-up below Courant one where the backward integration-point history rings (see :class:`~underworld3.systems.ddt.ForwardIntegrationPointsSemiLagrangian`). - The fourth semi-Lagrangian scheme, forward from nodes, carries a field - known at its nodes; a stress is formed at the integration points, so it - is not offered here. + ``"forward_nodes"`` launches the stress projected onto the continuous + history space from its nodes and from a lattice inside each element (see + :class:`~underworld3.systems.ddt.ForwardNodesSemiLagrangian`). ``"eulerian"`` transports the stress on the grid with the same streamline-upwind stabilisation the Eulerian solvers use, and gives the @@ -1610,9 +1606,10 @@ def stress_transport(self, value): f"stress_transport={value!r} is now {_RENAMED_TRANSPORTS[value]!r}", FutureWarning, stacklevel=2) value = _RENAMED_TRANSPORTS[value] - if value not in _STRESS_TRANSPORTS: + if value not in _SEMI_LAGRANGIAN_TRANSPORTS + ("lagrangian", "eulerian"): raise ValueError( - f"stress_transport must be one of {_STRESS_TRANSPORTS}, not {value!r}.") + f"stress_transport must be one of {_SEMI_LAGRANGIAN_TRANSPORTS} or " + f"'lagrangian' or 'eulerian', not {value!r}.") if self.Unknowns.DFDt is not None: raise RuntimeError( "the stress history already exists: set stress_transport before the " @@ -1836,17 +1833,19 @@ def _create_stress_history_ddt(self, order=2): **ddt_kwargs, **{k: v for k, v in common.items() if k != "smoothing"}, ) - elif self.stress_transport == "forward_integration_points": + elif self.stress_transport.startswith("forward_"): if ddt_kwargs: raise NotImplementedError( f"{type(cm).__name__} asks its stress history for " - f"{sorted(ddt_kwargs)}, which the forward flavour does not provide; " + f"{sorted(ddt_kwargs)}, which the forward flavours do not provide; " "use stress_transport='backward_nodes' for it.") - self.Unknowns.DFDt = uw.systems.ddt.ForwardIntegrationPointsSemiLagrangian( + self.Unknowns.DFDt = uw.systems.ddt.SemiLagrangian( self.mesh, sympy.Matrix.zeros(self.mesh.dim, self.mesh.dim), self.u.sym, - vtype=common["vtype"], degree=common["degree"], varsymbol=common["varsymbol"], + common["vtype"], + trace="forward", launch=self.stress_transport[len("forward_"):], + degree=common["degree"], varsymbol=common["varsymbol"], order=order, units=common["units"], ) elif self.stress_transport == "lagrangian": @@ -4463,8 +4462,7 @@ class SNES_AdvectionDiffusion(SNES_Scalar): does not take (``monotone_mode`` on a forward scheme, say) is refused. Only the value history is chosen: the diffusive flux history ``DFDt`` is always backward from the nodes. ``"forward_integration_points"`` - fits a linear polynomial per cell and so needs a degree-1 field; - ``"forward_nodes"`` runs in serial only. + fits a linear polynomial per cell and so needs a degree-1 field. old_frame_traceback : bool, default=False Use the old-frame semi-Lagrangian reach-back for the advective ``DuDt`` history on a moving mesh (free surface or interior-node diff --git a/src/underworld3/utilities/cell_polynomial_projection.py b/src/underworld3/utilities/cell_polynomial_projection.py index c07b04ad2..e14560047 100644 --- a/src/underworld3/utilities/cell_polynomial_projection.py +++ b/src/underworld3/utilities/cell_polynomial_projection.py @@ -106,6 +106,40 @@ def reference_coords(self, coords, cells): """Reference coordinates (PETSc's [-1, 1] frame) of points in their cells.""" return np.einsum("cij,cj->ci", self.invJ[cells], coords - self.v0[cells]) - 1.0 + def containing_cells(self, coords, tol=1.0e-9): + """Every local cell that contains each point. + + Returns ``(point, cell, lam)``: one row per (point, containing cell) + pair, with ``lam`` the point's distance inside that cell's nearest face + in reference units (the smallest barycentric coordinate of a simplex; + ``1 - max |xi|`` of a quadrilateral or hexahedron, through the same + affine cell map the fit uses). A point on a face or vertex shared by + several cells is in all of them (``lam`` within ``tol`` of zero); a + point strictly inside a cell is in that one only; a point in no local + cell has no row. + """ + coords = np.asarray(coords, dtype=np.float64).reshape(-1, self.dim) + if coords.shape[0] == 0 or self.ncells == 0: + empty = np.zeros(0, dtype=np.int64) + return empty, empty, np.zeros(0) + if getattr(self, "_centroid_tree", None) is None: + self._centroid_tree = uw.kdtree.KDTree(self.centroids) + # enough neighbours to hold every cell around a vertex + k = min(self.ncells, 16 if self.dim == 2 else 64) + _, near = self._centroid_tree.query(coords, k=k) + near = np.asarray(near, dtype=np.int64).reshape(coords.shape[0], k) + point = np.repeat(np.arange(coords.shape[0]), k) + cell = near.reshape(-1) + xi = self.reference_coords(np.repeat(coords, k, axis=0), cell) + if self.mesh.isSimplex: + # PETSc's reference simplex has vertices at -1 and +1 on each axis + lam_axes = 0.5 * (xi + 1.0) + lam = np.minimum(lam_axes.min(axis=1), 1.0 - lam_axes.sum(axis=1)) + else: + lam = 0.5 * (1.0 - np.abs(xi).max(axis=1)) + inside = lam >= -tol + return point[inside], cell[inside], lam[inside] + def locate(self, coords): """Owning local cell of each point (-1 when not on this rank) and its reference coordinates.""" coords = np.asarray(coords, dtype=np.float64) @@ -118,7 +152,8 @@ def locate(self, coords): # -- the fit ------------------------------------------------------------ - def fit(self, coords, values, nmin=None, patch_nnn=None, old=None, cond_max=1.0e6): + def fit(self, coords, values, nmin=None, patch_nnn=None, old=None, cond_max=1.0e6, + cell_local=False, cells=None): """Fit every cell; returns nodal values shaped like ``meshVar.data``. Parameters @@ -137,11 +172,23 @@ def fit(self, coords, values, nmin=None, patch_nnn=None, old=None, cond_max=1.0e advection lie on a line, and the P2 fit of a line is singular (measured: condition 1e300 at 92 particles, garbage that grew by 1e12 in ten steps through the read-back). + cell_local : a thin cell takes a lower-degree fit to its OWN points + (linear, or their mean when too few or collinear for a gradient) + instead of the linear patch fit to the nearest points, and a cell + with no points keeps ``old`` from the first fit on. No cell then + reads a point outside it, so the fit is the same on any partition. + cells : the local cell of each row, when the caller has assigned them + (see :meth:`containing_cells`); otherwise each point is located. """ coords = np.asarray(coords, dtype=np.float64).reshape(-1, self.dim) values = np.asarray(values, dtype=np.float64).reshape(coords.shape[0], -1) nc = values.shape[1] - cells, ok, xi = self.locate(coords) + if cells is None: + cells, ok, xi = self.locate(coords) + else: + cells = np.asarray(cells, dtype=np.int64) + ok = np.ones(cells.shape[0], dtype=bool) + xi = self.reference_coords(coords, cells) if cells.shape[0] else np.zeros_like(coords) c = cells[ok] B = self._scalar_basis(xi[ok]) # (Np, Nb) psi = values[ok] # (Np, nc) @@ -170,12 +217,14 @@ def fit(self, coords, values, nmin=None, patch_nnn=None, old=None, cond_max=1.0e self.n_empty = int((npc == 0).sum()) held = np.zeros(self.ncells, dtype=bool) - if old is not None and getattr(self, "_has_fit", False): + if old is not None and (cell_local or getattr(self, "_has_fit", False)): held = npc == 0 U[held] = np.asarray(old, dtype=np.float64).reshape(self.ncells, self.Nb, nc)[held] thin = np.nonzero(~dense & ~held)[0] self.n_thin = int(thin.shape[0]) - if thin.shape[0] > 0 and c.shape[0] > 0: + if cell_local and thin.shape[0] > 0: + U[thin] = self._cell_linear_fit(thin, c, xi[ok], psi, cond_max) + elif thin.shape[0] > 0 and c.shape[0] > 0: # Linear fit (monomials 1, xi_1, ..., xi_dim in the cell's frame) # to the nearest particles, evaluated at the cell's dof nodes. Xp = coords[ok] @@ -203,6 +252,29 @@ def fit(self, coords, values, nmin=None, patch_nnn=None, old=None, cond_max=1.0e self._has_fit = True return U.reshape(self.ncells * self.Nb, nc) + def _cell_linear_fit(self, cells, c, xi, psi, cond_max): + """Linear least-squares fit (1, xi_1, ..., xi_dim) of each of ``cells`` + to its own points, at the cell's dof nodes; the points' mean where they + cannot carry a gradient (fewer than dim + 2, or collinear).""" + npar = self.dim + 1 + slot = np.full(self.ncells, -1, dtype=np.int64) + slot[cells] = np.arange(cells.shape[0]) + mine = slot[c] >= 0 + s, A, p = slot[c[mine]], np.concatenate([np.ones((int(mine.sum()), 1)), xi[mine]], axis=1), psi[mine] + G = np.zeros((cells.shape[0], npar, npar)) + R = np.zeros((cells.shape[0], npar, p.shape[1])) + np.add.at(G, s, A[:, :, None] * A[:, None, :]) + np.add.at(R, s, A[:, :, None] * p[:, None, :]) + count = np.bincount(s, minlength=cells.shape[0]) + coef = np.zeros_like(R) + coef[:, 0, :] = R[:, 0, :] / np.maximum(count, 1)[:, None] # the mean + ev = np.linalg.eigvalsh(G) + linear = (count >= self.dim + 2) & (ev[:, -1] <= cond_max * np.maximum(ev[:, 0], 1e-300)) + if linear.any(): + coef[linear] = np.linalg.solve(G[linear], R[linear]) + Adof = np.concatenate([np.ones((self.Nb, 1)), self.xi_dof], axis=1) # (Nb, dim+1) + return np.einsum("ba,cak->cbk", Adof, coef) + def interpolate(self, U, coords): """The fitted polynomials evaluated at points (NaN off-rank): the FLIP read-back.""" U = np.asarray(U).reshape(self.ncells, self.Nb, -1) diff --git a/tests/parallel/test_1062_forward_stress_history_mpi.py b/tests/parallel/test_1062_forward_stress_history_mpi.py index 8327099b3..8ef5dcf46 100644 --- a/tests/parallel/test_1062_forward_stress_history_mpi.py +++ b/tests/parallel/test_1062_forward_stress_history_mpi.py @@ -17,7 +17,7 @@ pytestmark = [pytest.mark.level_2, pytest.mark.tier_b, pytest.mark.mpi(min_size=2), pytest.mark.timeout(600)] # BASELINES: the serial values (see the ledger) -XY_AT_A, XY_AT_B = -0.1190789, 0.0702257 +XY_AT_A, XY_AT_B = -0.1190800, 0.0702081 POINTS = np.array([[0.5, 0.2], [-0.3, -0.35]]) @@ -50,10 +50,8 @@ def test_the_forward_history_gives_the_serial_stress_on_every_rank(): kind, values, relocated = turned_over_maxwell_box() assert kind == "ForwardIntegrationPointsSemiLagrangian" # The serial values are the hard baseline (they pin the physics; regenerate - # them if a default changes). Parallel matches them to 1e-4, not to - # round-off: a cell's arrivals are summed into its least-squares fit in an - # order the partition sets, and ten steps of that reordering reach ~2e-5 on - # a mildly conditioned fit. A dropped or misrouted arrival would be far larger. - assert abs(values[0] - XY_AT_A) < 1.0e-4 and abs(values[1] - XY_AT_B) < 1.0e-4, values + # them if a default changes). Parallel matches them to the recorded figures + # (see test_1066); a dropped or misrouted arrival costs 1e-5 or more. + assert abs(values[0] - XY_AT_A) < 1.0e-6 and abs(values[1] - XY_AT_B) < 1.0e-6, values # the seam must actually have been crossed for this to test the parallel path assert relocated > 0 diff --git a/tests/parallel/test_1066_transport_schemes_mpi.py b/tests/parallel/test_1066_transport_schemes_mpi.py new file mode 100644 index 000000000..1e302347d --- /dev/null +++ b/tests/parallel/test_1066_transport_schemes_mpi.py @@ -0,0 +1,78 @@ +"""Every stress and advection history in parallel: np >= 3 equals serial. + +Stress: the turned-over Maxwell box of test_1062 (a shear modulus varying in x, +two counter-rotating cells between no-slip walls, an unstructured mesh), so +the stress is non-uniform and the flow crosses seams of either orientation. +Advection: a rotating Gaussian in a square box (the flow crosses every wall), +P2, a quarter turn, with each of the four semi-Lagrangian value histories. The values at two points after the +run must be the serial ones. At least three ranks: two ranks meet only along +one seam, while three or more also meet at points, where an arrival can be +handed to either of two other ranks. +""" +import numpy as np +import pytest +import sympy + +import underworld3 as uw +from test_1062_forward_stress_history_mpi import turned_over_maxwell_box + +pytestmark = [pytest.mark.level_2, pytest.mark.tier_b, pytest.mark.mpi(min_size=3), pytest.mark.timeout(1200)] + +# BASELINES: the serial values at the points of each test (2026-09-27) +STRESS_XY = { + "backward_nodes": (-0.1001236, 0.1000386), + "backward_integration_points": (-0.1166604, 0.0682694), + "forward_integration_points": (-0.1190800, 0.0702081), + "forward_nodes": (-0.1190115, 0.0701718), + "lagrangian": (-0.1214269, 0.0672075), + "eulerian": (-0.1186881, 0.0699410), +} +ADVECTED_T = { + "backward_nodes": (0.7259154, 0.0751478), + "backward_integration_points": (0.7533908, 0.0739654), + "forward_integration_points": (0.6347829, 0.0836136), + "forward_nodes": (0.7694358, 0.0721413), +} +# np 3, 4 and 6 give the serial values to the 7 figures recorded: the history +# projections are converged to 1e-10 and no stage depends on which rank, or +# which of two cells, a point is assigned to. A misrouted or dropped value +# costs 1e-5 or more. +ATOL = 1.0e-6 +T_POINTS = np.array([[0.0, 0.5], [-0.2, 0.35]]) + + +def rotating_gaussian(transport, steps=16, dt=np.pi / 32): + mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(-1.0, -1.0), maxCoords=(1.0, 1.0), + cellSize=0.1, qdegree=3, regular=False) + x, y = mesh.X + degree = 1 if transport == "forward_integration_points" else 2 + T = uw.discretisation.MeshVariable("T_rg", mesh, 1, degree=degree) + T.array[:, 0, 0] = uw.function.evaluate( + sympy.exp(-((x - 0.5) ** 2 + y ** 2) / (2 * 0.1 ** 2)), T.coords).reshape(-1) + adv = uw.systems.AdvDiffusionSLCN(mesh, u_Field=T, V_fn=sympy.Matrix([[-y, x]]), order=1, + transport=transport) + adv.constitutive_model = uw.constitutive_models.DiffusionModel + adv.constitutive_model.Parameters.diffusivity = 1.0e-3 + for wall in ("Left", "Right", "Top", "Bottom"): + adv.add_dirichlet_bc(0.0, wall) + adv.tolerance = 1.0e-10 + for _ in range(steps): + adv.solve(timestep=dt) + return np.asarray(uw.function.global_evaluate(T.sym[0], T_POINTS)).reshape(-1) + + +@pytest.mark.parametrize("transport", [ + pytest.param(t, marks=pytest.mark.xfail( + uw.mpi.size >= 4, strict=True, reason="TODO(BUG): the particle history differs from serial by 1.2e-5 " + "at np >= 4 (np 3 matches); the particle values, their ownership and the cell " + "proxy's fit are partition-independent -- cause not yet found")) + if t == "lagrangian" else t for t in STRESS_XY]) +def test_every_stress_history_gives_the_serial_stress_on_every_rank(transport): + _kind, values, _relocated = turned_over_maxwell_box(transport) + assert np.allclose(values, STRESS_XY[transport], atol=ATOL), (transport, values) + + +@pytest.mark.parametrize("transport", list(ADVECTED_T)) +def test_every_value_history_gives_the_serial_field_on_every_rank(transport): + values = rotating_gaussian(transport) + assert np.allclose(values, ADVECTED_T[transport], atol=ATOL), (transport, values) diff --git a/tests/test_1059_stress_transport.py b/tests/test_1059_stress_transport.py index 099d14095..12625e132 100644 --- a/tests/test_1059_stress_transport.py +++ b/tests/test_1059_stress_transport.py @@ -226,6 +226,7 @@ def _maxwell_shear(transport, order, steps=20, dt=0.1, integrator="bdf", solver= "backward_nodes": "BackwardNodesSemiLagrangian", "backward_integration_points": "BackwardIntegrationPointsSemiLagrangian", "forward_integration_points": "ForwardIntegrationPointsSemiLagrangian", + "forward_nodes": "ForwardNodesSemiLagrangian", "lagrangian": "Lagrangian", "eulerian": "EulerianSUPG", } @@ -292,9 +293,6 @@ def test_the_former_stress_transport_names_still_select_their_scheme(): with pytest.warns(FutureWarning, match=new): stokes.stress_transport = old assert stokes.stress_transport == new - # a stress is formed at the integration points: it is not carried from the nodes - with pytest.raises(ValueError, match="stress_transport must be"): - stokes.stress_transport = "forward_nodes" def test_semi_lagrangian_selects_its_scheme_by_trace_and_launch(): @@ -314,8 +312,6 @@ def test_semi_lagrangian_selects_its_scheme_by_trace_and_launch(): # an option the chosen scheme does not take is refused, not dropped with pytest.raises(TypeError, match="monotone_mode"): uw.systems.ddt.SemiLagrangian(mesh, T.sym, V, trace="forward", degree=1, monotone_mode="clamp") - with pytest.raises(NotImplementedError, match="integration points"): - uw.systems.ddt.SemiLagrangian(mesh, T.sym, V, trace="forward", degree=1).commit_flux_to_history(T.sym) with pytest.warns(FutureWarning, match="ForwardIntegrationPointsSemiLagrangian"): assert uw.systems.ddt.ForwardSemiLagrangian is uw.systems.ddt.ForwardIntegrationPointsSemiLagrangian @@ -766,11 +762,11 @@ def test_the_store_smoothing_is_the_coefficient_times_the_local_cell_size_square stokes = _one_shear_step("backward_integration_points", dt=1.0) history = stokes.DFDt assert history.store_smoothing == 0.0 - assert history._commit_projection.smoothing == 0.0 + assert history._nodal_projections["flux"][1].smoothing == 0.0 history.store_smoothing = 0.05 stokes.solve(timestep=1.0, zero_init_guess=False) # the projection's smoothing is now a field: c times the cell-size field squared - alpha = history._commit_projection.smoothing + alpha = history._nodal_projections["flux"][1].smoothing x0 = np.array([[0.1, 0.1]]) h = float(np.asarray(uw.function.evaluate(stokes.mesh.cell_size(), x0)).reshape(-1)[0]) a = float(np.asarray(uw.function.evaluate(alpha, x0)).reshape(-1)[0]) diff --git a/tests/test_1102_forward_nodes_rotating_gaussian.py b/tests/test_1102_forward_nodes_rotating_gaussian.py index 9a990ba93..650d92d84 100644 --- a/tests/test_1102_forward_nodes_rotating_gaussian.py +++ b/tests/test_1102_forward_nodes_rotating_gaussian.py @@ -41,7 +41,7 @@ def _run(mesh, walls): def test_forward_from_nodes_on_the_disc(): uw.reset_default_model() err, peak = _run(_disc(24), ("Upper",)) - assert abs(err - 0.01996) < 0.002, err # BASELINE (2026-09-26) + assert abs(err - 0.01780) < 0.0018, err # BASELINE (2026-09-27) assert abs(peak - EXACT_PEAK) < 0.002, (peak, EXACT_PEAK) @@ -50,5 +50,5 @@ def test_forward_from_nodes_holds_the_box(): mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(-1.0, -1.0), maxCoords=(1.0, 1.0), cellSize=2.0 / 24, qdegree=3, regular=False) err, peak = _run(mesh, ("Left", "Right", "Top", "Bottom")) - assert abs(err - 0.0617) < 0.006, err # BASELINE (2026-09-26) + assert abs(err - 0.0657) < 0.0065, err # BASELINE (2026-09-27) assert abs(peak - EXACT_PEAK) < 0.005, (peak, EXACT_PEAK) From 98c4193617f76fbde62e7bcf09ad85191d04527c Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sun, 27 Sep 2026 01:55:53 -0700 Subject: [PATCH 04/21] Review (b20c992e): inflow across seams, NaN fallback, reproducible history projections - forward from nodes: an inflow node is one whose back-reflected point the global restore moves (a per-rank "in domain" test called every partition face a boundary); ownership of seam nodes read from the global section (_owned_rows) instead of a write-and-read-back; the tracked field copied, not evaluated, at the nodes; a strictly-inside arrival fitted in its one cell only; containing_cells falls back to the locator for a point whose cell is not among the nearest centroids; quadrilateral centroids corrected. - global_evaluate: the containment round keeps a finite rbf value where the FE value is NaN, and runs only where the cell hint is authoritative (no DMLocatePoints collective inside it); the deadlock note says so. - every history projection is a CG/Jacobi solve (linear_solver moved to the projection mixin) to 1e-12, from zero: the warm start depended on a state a restart does not restore. - Eulerian record uses _tracked_field; _tracked_field refuses another mesh's variable; stress_transport names map through one (trace, launch) table. - tests: test_1066 resets the model per case, asserts the forward exchanges ran, gives the value histories an inflow, marks the particle case #797 (xfail, np >= 4); test_1063 covers forward_nodes; test_1102 held to 1e-4. test_1066 at np 4 is the regression test for the containment round. - TODO(DESIGN) notes: face points offered to every rank; the two forward flavours' arrival rules. np 3/4/6: 10 passed (+1 xfail at 4/6); serial: 388 passed. Underworld development team with AI support from Claude Code Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo --- .../design/REMESH_FIELD_TRANSFER_DESIGN.md | 4 +- .../design/lagged-clone-sl-history.md | 2 +- src/underworld3/function/_function.pyx | 56 +++++--- src/underworld3/swarm.py | 2 +- src/underworld3/systems/ddt.py | 126 +++++++++++------- src/underworld3/systems/solvers.py | 120 +++++++++-------- .../utilities/cell_polynomial_projection.py | 37 +++-- .../test_1066_transport_schemes_mpi.py | 19 ++- tests/test_1063_stress_history_restart.py | 2 +- ...st_1102_forward_nodes_rotating_gaussian.py | 4 +- 10 files changed, 226 insertions(+), 146 deletions(-) diff --git a/docs/developer/design/REMESH_FIELD_TRANSFER_DESIGN.md b/docs/developer/design/REMESH_FIELD_TRANSFER_DESIGN.md index 73cf70587..0fd2cb204 100644 --- a/docs/developer/design/REMESH_FIELD_TRANSFER_DESIGN.md +++ b/docs/developer/design/REMESH_FIELD_TRANSFER_DESIGN.md @@ -204,7 +204,7 @@ REMAP. ## 7. The band-aid (already landed) and its relationship to the true fix -`ddt.py` `SemiLagrangian._record_psi_star_from_field_data()` + a guarded call: +`ddt.py` `BackwardNodesSemiLagrangian._copy_tracked_field()` (formerly the parallel-only `_record_psi_star_from_field_data`; since 2026-09-27 used in serial too) + a guarded call: under MPI, when `psi_fn` is a single mesh-variable component on this mesh, the per-step "record current field into `psi_star[0]`" copies the field's nodal data **directly** instead of evaluating it at its own (on-vertex) coords. @@ -255,6 +255,6 @@ Relationship to the true fix: |---|---|---| | `discretisation/discretisation_mesh.py` | `_deform_mesh` @2001, `nuke_coords_and_rebuild` @1757 (per-var refill @1890), `vars` registry @3082 | coords/cache rebuild; scoped-refill change (§5) | | `meshing/smoothing.py` | movers @1570/1861/2639 (per-outer `_deform_mesh`); `smooth_mesh_interior` @2683; `OT_adapt`, `follow_metric` | adapt op owns transfer; mover sweep refill scope | -| `systems/ddt.py` | `SemiLagrangian` (history @119, shift @2035, re-record @2085, band-aid `_record_psi_star_from_field_data`); flavors @98/108/119/146 | history policy = REMAP/ALE; `on_remesh` hook | +| `systems/ddt.py` | `SemiLagrangian` (history @119, shift @2035, re-record @2085, nodal copy `_copy_tracked_field`, formerly `_record_psi_star_from_field_data`); flavors @98/108/119/146 | history policy = REMAP/ALE; `on_remesh` hook | | `systems/solvers.py` | `AdvDiffusionSLCN`, NS, VE (`DuDt`/`DFDt` @997/1354) | use DDt; nothing solver-specific for ALE | | `swarm.py` | `_proxy_stale` @309, `_update` @1019/2129 | proxy REINIT + adapt-triggered staleness (§8) | diff --git a/docs/developer/design/lagged-clone-sl-history.md b/docs/developer/design/lagged-clone-sl-history.md index 04ba8bd2d..26b19530f 100644 --- a/docs/developer/design/lagged-clone-sl-history.md +++ b/docs/developer/design/lagged-clone-sl-history.md @@ -208,7 +208,7 @@ despite old-frame being active, because the standard `store_result` path **re-records** `psi_star[0]` by evaluating `psi_fn` on the *deformed* mesh at centroid-shifted nodes — injecting boundary-layer interpolation error that grows with `h_max` and then rides the old-geometry sample. Recording the history by a -**direct nodal carry** (reusing the parallel `_record_psi_star_from_field_data` +**direct nodal carry** (reusing the nodal copy `_copy_tracked_field`, formerly `_record_psi_star_from_field_data` path) restores the prototype's exact behaviour. This is the "store primitives, not re-derived values" principle of invariant 3, in miniature. diff --git a/src/underworld3/function/_function.pyx b/src/underworld3/function/_function.pyx index 0c55057dc..e35354b1c 100644 --- a/src/underworld3/function/_function.pyx +++ b/src/underworld3/function/_function.pyx @@ -580,12 +580,13 @@ def global_evaluate_nd( expr, # DEADLOCK SAFETY — read before editing. Every collective here (allgather, # Allreduce) runs unconditionally on the IDENTICAL global set on every # rank, so all ranks stay in lockstep (n_ext_total is itself a reduced - # value, so the `> 0` guard is taken identically everywhere). The per-rank - # value MUST come from the LOCAL rbf path (rbf=True): the FE interpolation - # path (petsc_interpolate / DMInterpolation) is itself collective and would - # desync here, because each rank classifies the same global set against its - # own domain (different interior-point counts) → hang. Never route the - # fallback value through FE interpolation. + # value, so the `> 0` guard is taken identically everywhere). The + # best-claim value comes from the LOCAL rbf path (rbf=True), evaluated on + # the whole global set. The FE path is used only in the containment round, + # only on meshes whose cell hint is authoritative (no DMLocatePoints, so no + # collective inside it), and every rank calls it on the points it contains + # -- possibly none. Never call the FE path on the global set, and never on + # a mesh that needs DMLocatePoints. # # Serial is left untouched (the serial path above already extrapolates from # the true nearest cell). Escape hatch: GE_LOCAL_FALLBACK=0 restores the @@ -660,22 +661,33 @@ def global_evaluate_nd( expr, # for points no rank contains). Every rank calls evaluate_nd, on # the points it contains -- possibly none -- as the first pass does # on the points it received, so the collectives stay in lockstep. - contains = np.asarray(mesh._robust_owning_cells(all_ext)) >= 0 - my_owner = np.where(contains, comm.rank, comm.size).astype(np.int32) - owner = np.empty(n_ext_total, dtype=np.int32) - comm.Allreduce([my_owner, MPI.INT], [owner, MPI.INT], op=MPI.MIN) - mine = owner == comm.rank - fe_vals, _fe_flag = evaluate_nd( - expr, np.ascontiguousarray(all_ext[mine]), rbf=rbf, evalf=evalf, - verbose=False, simplify=simplify, check_extrapolated=True,) - contrib_fe = np.zeros((n_ext_total,) + expr_shape, dtype=np.float64) - if mine.any(): - contrib_fe[mine] = np.asarray(fe_vals, dtype=np.float64).reshape((-1,) + expr_shape) - fe_val = np.empty_like(contrib_fe) - comm.Allreduce([contrib_fe, MPI.DOUBLE], [fe_val, MPI.DOUBLE], op=MPI.SUM) - contained = owner < comm.size - best_val[contained] = fe_val[contained] - best_flag[contained] = 0 + # + # Only where the cell hint is authoritative: there the FE path + # locates without DMLocatePoints, so a rank holding none of the + # points takes no collective. Elsewhere (warped quads/hexes) the + # rbf value stands, as before. + all_continuous = all( + getattr(varfn.meshvar(), "continuous", True) for varfn in varfns) + if mesh._hint_is_authoritative(all_continuous): + contains = np.asarray(mesh._robust_owning_cells(all_ext)) >= 0 + my_owner = np.where(contains, comm.rank, comm.size).astype(np.int32) + owner = np.empty(n_ext_total, dtype=np.int32) + comm.Allreduce([my_owner, MPI.INT], [owner, MPI.INT], op=MPI.MIN) + mine = owner == comm.rank + fe_vals, _fe_flag = evaluate_nd( + expr, np.ascontiguousarray(all_ext[mine]), rbf=rbf, evalf=evalf, + verbose=False, simplify=simplify, check_extrapolated=True,) + contrib_fe = np.zeros((n_ext_total,) + expr_shape, dtype=np.float64) + if mine.any(): + contrib_fe[mine] = np.asarray(fe_vals, dtype=np.float64).reshape((-1,) + expr_shape) + fe_val = np.empty_like(contrib_fe) + comm.Allreduce([contrib_fe, MPI.DOUBLE], [fe_val, MPI.DOUBLE], op=MPI.SUM) + # a located point whose FE value is NaN is why the fallback + # exists (see above): its finite rbf value stands + use = (owner < comm.size) & np.isfinite( + fe_val.reshape(n_ext_total, -1)).all(axis=1) + best_val[use] = fe_val[use] + best_flag[use] = 0 # Scatter this rank's segment of the global set back to its points. offset = int(counts[:comm.rank].sum()) diff --git a/src/underworld3/swarm.py b/src/underworld3/swarm.py index 34043efc3..bf3f56c04 100644 --- a/src/underworld3/swarm.py +++ b/src/underworld3/swarm.py @@ -5926,7 +5926,7 @@ def estimate_dt(self, V_fn): # TODO(BUG): with evalf=True evaluate() flags EVERY point as extrapolated, so # at np > 1 this raises the 'not located on this rank' warning for particles # that are all local (seen on the Lagrangian stress history, 2026-09-27). - # See planning file: underworld.md (Bugs section, 2026-09-27) + # See issue #798 vel = uw.function.evaluate(V_fn, self._particle_coordinates.data, evalf=True) # If vel is unit-aware (UnitAwareArray), nondimensionalise it to get diff --git a/src/underworld3/systems/ddt.py b/src/underworld3/systems/ddt.py index d7a034f6a..4eba59a2f 100644 --- a/src/underworld3/systems/ddt.py +++ b/src/underworld3/systems/ddt.py @@ -224,10 +224,28 @@ def _history_units(psi_fn, units=None): # A history projection's result is the carried field itself, so it is solved -# to convergence: a mass-matrix solve left at a projection's default tolerance -# (1e-4) carries an error of that size into every step, and the error depends -# on the partition. Converging it costs a few more iterations. -_HISTORY_PROJECTION_TOLERANCE = 1.0e-10 +# to convergence, by CG with a Jacobi preconditioner (the matrix is SPD), and +# from zero: a mass-matrix solve left at a projection's default tolerance (1e-4) +# carries an error of that size into every step, with an aggregating multigrid +# that error depends on the partition, and a solve warmed from the previous +# step's field depends on a state a restart does not restore. +_HISTORY_PROJECTION_TOLERANCE = 1.0e-12 + + +def _owned_rows(var): + """Rows of ``var.data`` this rank owns; a ghost row copies another rank's + (its point has no place in the global section).""" + _is, subdm = var.mesh.dm.createSubDM(var.field_id) + local, global_ = subdm.getLocalSection(), subdm.getGlobalSection() + nc = var.num_components + owned = np.zeros(np.asarray(var.data).shape[0], dtype=bool) + p_start, p_end = local.getChart() + for p in range(p_start, p_end): + ndof = local.getDof(p) + if ndof and global_.getOffset(p) >= 0: + offset = local.getOffset(p) + owned[offset // nc:(offset + ndof) // nc] = True + return owned def _tracked_field(mesh, psi_fn, store): @@ -242,10 +260,10 @@ def _tracked_field(mesh, psi_fn, store): """ if isinstance(psi_fn, uw.discretisation.MeshVariable): field = psi_fn + if field.mesh is not mesh: + return None else: hit = uw.discretisation.meshVariable_lookup_by_symbol(mesh, psi_fn) - if hit is None and isinstance(psi_fn, sympy.MatrixBase) and psi_fn.shape == (1, 1): - hit = uw.discretisation.meshVariable_lookup_by_symbol(mesh, psi_fn[0, 0]) if hit is None: return None field, component = hit @@ -1057,14 +1075,14 @@ def commit_flux_to_history(self, flux, verbose=False): # is a one-shot Galerkin projection and not a fixed-point iteration # (which at a yield kink admits the wrong branch). self._psi_star_projection_solver.smoothing = 0.0 - self._psi_star_projection_solver.solve(verbose=verbose) + self._psi_star_projection_solver.solve(verbose=verbose, zero_init_guess=True) for k, (i, j) in enumerate(self._psi_star_indep_indices): values = np.asarray(self._psi_star_flat_var.data[:, k]) level_0.data[:, level_0._data_layout(i, j)] = values else: self._psi_star_projection_solver.uw_function = flux self._psi_star_projection_solver.smoothing = 0.0 - self._psi_star_projection_solver.solve(verbose=verbose) + self._psi_star_projection_solver.solve(verbose=verbose, zero_init_guess=True) for level in range(self.order - 1, 0, -1): self.psi_star[level].data[...] = ( @@ -1181,12 +1199,12 @@ def _project_nodally(self, expr, name="flux", smoothing=0.0, verbose=False): varsymbol=rf"{{{name}^{{\mathrm{{nodal}}}}_{{{self.instance_number}}}}}") projection = uw.systems.solvers.SNES_MultiComponent_Projection( self.mesh, u_Field=target, n_components=len(columns), verbose=self.verbose) - projection.tolerance = _HISTORY_PROJECTION_TOLERANCE + projection.linear_solver(rtol=_HISTORY_PROJECTION_TOLERANCE) self._nodal_projections[name] = (target, projection) target, projection = self._nodal_projections[name] projection.uw_function = sympy.Matrix([[expr[i, j] for (i, j) in columns]]) projection.smoothing = smoothing - projection.solve(verbose=verbose) + projection.solve(verbose=verbose, zero_init_guess=True) return target def _write_inflow(self, var, coords, rows): @@ -1760,7 +1778,7 @@ def _setup_projections(self): verbose=False, ) self._psi_star_use_multicomponent = True - self._psi_star_projection_solver.tolerance = _HISTORY_PROJECTION_TOLERANCE + self._psi_star_projection_solver.linear_solver(rtol=_HISTORY_PROJECTION_TOLERANCE) self._psi_star_projection_solver.uw_function = self._build_projection_source( self.psi_fn) @@ -1775,16 +1793,12 @@ def update_history_fn(self): evaluation of ``psi_fn``; an L2 projection for expressions that ``evaluate`` cannot handle (e.g. containing derivatives). """ - if self._psi_meshVar is not None: - try: - self.psi_star[0].data[...] = self._psi_meshVar.data[...] - return - except ValueError: - # Sanctioned fallthrough: the tracked variable's nodal - # layout differs from psi_star's (different degree / - # continuity), so the direct copy cannot broadcast — - # evaluate psi_fn at psi_star's own nodes instead. - pass + field = _tracked_field( + self.mesh, self._psi_meshVar if self._psi_meshVar is not None else self.psi_fn, + self.psi_star[0]) + if field is not None: + self.psi_star[0].data[...] = field.data[...] + return try: self.psi_star[0].data[...] = np.asarray(_to_nondim_ndarray(uw.function.evaluate( @@ -1797,7 +1811,7 @@ def update_history_fn(self): # expressions containing derivatives (e.g. flux terms) — # project them onto psi_star[0] instead. self._setup_projections() - self._psi_star_projection_solver.solve() + self._psi_star_projection_solver.solve(zero_init_guess=True) def initialise_history(self): r"""Initialize all history slots to the current value of :math:`\psi`. @@ -3028,7 +3042,7 @@ def __init__( verbose=False, ) self._psi_star_use_multicomponent = True - self._psi_star_projection_solver.tolerance = _HISTORY_PROJECTION_TOLERANCE + self._psi_star_projection_solver.linear_solver(rtol=_HISTORY_PROJECTION_TOLERANCE) # We should find a way to add natural bcs here # (self.Unknowns.u carried as a symbol from solver to solver) @@ -3218,7 +3232,7 @@ def initialise_history(self): # semantics are consistent. self._psi_star_projection_solver.uw_function = self._build_projection_source(self.psi_fn) self._psi_star_projection_solver.smoothing = 0.0 - self._psi_star_projection_solver.solve() + self._psi_star_projection_solver.solve(zero_init_guess=True) if getattr(self, '_psi_star_use_multicomponent', False): # Fan out flat result to tensor psi_star[0] for k, (i, j) in enumerate(self._psi_star_indep_indices): @@ -3524,7 +3538,7 @@ def _record_current_field_into_history( self.psi_fn ) self._psi_star_projection_solver.smoothing = 0.0 - self._psi_star_projection_solver.solve(verbose=verbose) + self._psi_star_projection_solver.solve(verbose=verbose, zero_init_guess=True) # For tensor vtypes the projection writes into the flat (1, Nc) variable, # so we must fan it back out to psi_star[0] — otherwise subsequent @@ -4670,6 +4684,11 @@ def update_post_solve( +# TODO(DESIGN): the forward integration-point history gives an arrival on a +# shared face to one cell (nearest centroid) through this helper, where the +# forward nodal history (_arrivals_by_cell) fits it in every containing cell. +# Integration points almost never arrive on a face, so the two agree in +# practice; one rule for both would remove the difference. def _hand_arrivals_to_owners(X, columns, locate): """Give every arrival to the rank whose cell it landed in. @@ -5177,7 +5196,8 @@ def _object_viewer(self): def _record_current(self): """Snapshot slot 0 <- the current solution and velocity.""" ps = self.psi_snap[0] - field = _tracked_field(self.mesh, self._psi_meshVar or self.psi_fn, ps) + field = _tracked_field( + self.mesh, self._psi_meshVar if self._psi_meshVar is not None else self.psi_fn, ps) if field is not None: ps.data[...] = field.data[...] else: @@ -5264,11 +5284,11 @@ def update_forcing_history(self, forcing_fn=None, evalf=False, verbose=False): self._forcing_projection = uw.systems.solvers.SNES_MultiComponent_Projection( self.mesh, u_Field=self._forcing_flat, n_components=len(columns), verbose=self.verbose) - self._forcing_projection.tolerance = _HISTORY_PROJECTION_TOLERANCE + self._forcing_projection.linear_solver(rtol=_HISTORY_PROJECTION_TOLERANCE) self._forcing_projection.uw_function = sympy.Matrix( [[forcing[i, j] for (i, j) in columns]]) self._forcing_projection.smoothing = 0.0 - self._forcing_projection.solve(verbose=verbose) + self._forcing_projection.solve(verbose=verbose, zero_init_guess=True) points = np.asarray(self.forcing_star.integration_points).reshape(-1, self.mesh.cdim) for column in range(len(columns)): self.forcing_snap.data[:, column] = np.asarray( @@ -5579,10 +5599,10 @@ def _evaluate_at_launch(self, expr): if self._flux_projection is None: self._flux_projection = uw.systems.solvers.SNES_MultiComponent_Projection( self.mesh, u_Field=self._flux_var, n_components=self.num_components) - self._flux_projection.tolerance = _HISTORY_PROJECTION_TOLERANCE + self._flux_projection.linear_solver(rtol=_HISTORY_PROJECTION_TOLERANCE) self._flux_projection.uw_function = sympy.Matrix([[expr[i, j] for (i, j) in self._components]]) self._flux_projection.smoothing = self.flux_smoothing - self._flux_projection.solve() + self._flux_projection.solve(zero_init_guess=True) out = np.empty_like(self._launch_values) for k in range(self.num_components): out[:, k] = _to_nondim_ndarray(uw.function.evaluate(self._flux_var.sym[0, k], self._launch)).reshape(-1) @@ -5830,12 +5850,8 @@ def __init__(self, mesh, psi_fn, V_fn, vtype=VarType.SCALAR, degree=1, varsymbol # cells to the linear fallback; three gains nothing) self._interior = np.asarray(mesh._get_coords_for_basis(self.degree + 2, continuous=False) ).reshape(-1, mesh.cdim) - # A node on a partition seam is on both ranks and is launched once, by its - # owner: writing the rank into the store and reading it back after the - # ghost update leaves every copy holding its owner's rank. - self.psi_star[0].data[:, 0] = uw.mpi.rank - self._owned = np.asarray(self.psi_star[0].data[:, 0]) == uw.mpi.rank - self.psi_star[0].data[:, :] = 0.0 + # a node on a partition seam is on both ranks and is launched once, by its owner + self._owned = _owned_rows(self.psi_star[0]) self._n_relocated = 0 self._n_v = 2 self._init_coefficient_expressions(1, self.theta, with_exp=True) @@ -5873,8 +5889,6 @@ def _values_at(self, expr, X): np.asarray(_to_nondim_ndarray(uw.function.evaluate(expr[i, j], X))).reshape(-1) for (i, j) in self._components]) - _FACE_TOLERANCE = 1.0e-9 - def _arrivals_by_cell(self, X, values): """Every arrival with every cell that contains it, on the rank that owns the cell: one row per (arrival, cell). @@ -5888,11 +5902,17 @@ def _arrivals_by_cell(self, X, values): same on any partition. """ pj = self._projector - tol = self._FACE_TOLERANCE - point, cell, lam = pj.containing_cells(X, tol) + tol = pj.FACE_TOLERANCE + point, cell, lam = pj.containing_cells(X) + inside = lam > tol strict = np.zeros(X.shape[0], dtype=bool) - strict[point[lam > tol]] = True - keep = strict[point] + strict[point[inside]] = True + # a strictly-inside point belongs to its one cell, even where a larger + # neighbour puts it within the tolerance of a face + keep = inside + # TODO(DESIGN): every face point is offered, including those on faces + # inside this rank's partition (no-slip wall nodes, stagnant regions); + # only faces on a partition seam need to travel. offered_X, offered_v = X[~strict], values[~strict] if uw.mpi.size > 1: comm = uw.mpi.comm @@ -5904,7 +5924,7 @@ def _arrivals_by_cell(self, X, values): n_mine = parts_X[uw.mpi.rank].shape[0] else: mine_from, n_mine = 0, offered_X.shape[0] - q, qcell, _ = pj.containing_cells(offered_X, tol) + q, qcell, _ = pj.containing_cells(offered_X) own = (q >= mine_from) & (q < mine_from + n_mine) self._n_relocated = int(np.unique(q[~own]).size) return (np.concatenate([X[point[keep]], offered_X[q]], axis=0), @@ -5925,12 +5945,20 @@ def _reconstruct(self, arrivals, values, source): return np.array(self._project_nodally(self._fit_var.sym, name="fit").data) # ------------------------------------------------------------------ + def _field_at_nodes(self): + """The tracked field at the nodes: its own nodal values when it is a mesh + variable laid out like the store, else evaluated there.""" + field = _tracked_field(self.mesh, self._psi_fn, self.psi_star[0]) + if field is not None: + return np.array(field.data) + return self._values_at(self._psi_fn, self._nodes()) + def initialise_history(self): """Start from the current field at the nodes. A history already placed by :meth:`commit_flux_to_history` is the start, and is kept.""" self.characteristics.initialise_levels(self._n_v) if not self._history_committed: - self.psi_star[0].data[:, :] = self._values_at(self._psi_fn, self._nodes()) + self.psi_star[0].data[:, :] = self._field_at_nodes() self._history_initialised = True def update_pre_solve(self, dt, evalf=False, verbose=False, store_result=True, **_ignored): @@ -5958,8 +5986,8 @@ def update_pre_solve(self, dt, evalf=False, verbose=False, store_result=True, ** # the tracked field, or the committed store, which is a polynomial # inside each element, so its interior values are exact source = self._psi_fn if store_result else self.psi_star[0].sym - at_owned = (self._values_at(source, owned) if store_result - else np.asarray(self.psi_star[0].data)[self._owned]) + at_owned = (self._field_at_nodes() if store_result + else np.asarray(self.psi_star[0].data))[self._owned] values = np.vstack([at_owned, self._values_at(source, self._interior)]) key = (_basis_key_of(self.psi_star[0]), "launch") arrivals = np.asarray(trace.departure_points(key, launch, (("first", 0, -float(dt)),), @@ -5968,9 +5996,13 @@ def update_pre_solve(self, dt, evalf=False, verbose=False, store_result=True, ** if self._inflow_value is not None: # an owned node whose back-trace leaves the domain holds fluid that # entered this step (a ghost copy takes its owner's value) + # (the global-domain test the other flavours use: restoring the + # point moves it; a per-rank "in domain" test calls a partition + # face a boundary) back = 2.0 * owned - arrivals[:owned.shape[0]] + restored = np.asarray(self.mesh.return_coords_to_bounds(back.copy())).reshape(back.shape) entered = np.zeros(nodes.shape[0], dtype=bool) - entered[np.flatnonzero(self._owned)] = ~np.asarray(self.mesh.points_in_domain(back), dtype=bool) + entered[np.flatnonzero(self._owned)] = np.any(np.abs(restored - back) > 0.0, axis=1) inflow = self._values_at(self._inflow_record(), nodes) carried[entered] = inflow[entered] self.psi_star[0].data[:, :] = carried diff --git a/src/underworld3/systems/solvers.py b/src/underworld3/systems/solvers.py index 507ae9412..0c6cb931d 100644 --- a/src/underworld3/systems/solvers.py +++ b/src/underworld3/systems/solvers.py @@ -463,10 +463,12 @@ def _invalidate_solution_cache(u): # The semi-Lagrangian schemes a solver can build its history with, named # "_" after the arguments of ddt.SemiLagrangian. -_SEMI_LAGRANGIAN_TRANSPORTS = ( - "backward_nodes", "backward_integration_points", - "forward_integration_points", "forward_nodes", -) +_SEMI_LAGRANGIAN_TRANSPORTS = { + "backward_nodes": ("backward", "nodes"), + "backward_integration_points": ("backward", "integration_points"), + "forward_integration_points": ("forward", "integration_points"), + "forward_nodes": ("forward", "nodes"), +} _RENAMED_TRANSPORTS = { "semi_lagrangian": "backward_nodes", "integration_point": "backward_integration_points", @@ -1606,9 +1608,9 @@ def stress_transport(self, value): f"stress_transport={value!r} is now {_RENAMED_TRANSPORTS[value]!r}", FutureWarning, stacklevel=2) value = _RENAMED_TRANSPORTS[value] - if value not in _SEMI_LAGRANGIAN_TRANSPORTS + ("lagrangian", "eulerian"): + if value not in (*_SEMI_LAGRANGIAN_TRANSPORTS, "lagrangian", "eulerian"): raise ValueError( - f"stress_transport must be one of {_SEMI_LAGRANGIAN_TRANSPORTS} or " + f"stress_transport must be one of {tuple(_SEMI_LAGRANGIAN_TRANSPORTS)} or " f"'lagrangian' or 'eulerian', not {value!r}.") if self.Unknowns.DFDt is not None: raise RuntimeError( @@ -1833,7 +1835,7 @@ def _create_stress_history_ddt(self, order=2): **ddt_kwargs, **{k: v for k, v in common.items() if k != "smoothing"}, ) - elif self.stress_transport.startswith("forward_"): + elif _SEMI_LAGRANGIAN_TRANSPORTS.get(self.stress_transport, ("",))[0] == "forward": if ddt_kwargs: raise NotImplementedError( f"{type(cm).__name__} asks its stress history for " @@ -1844,7 +1846,7 @@ def _create_stress_history_ddt(self, order=2): sympy.Matrix.zeros(self.mesh.dim, self.mesh.dim), self.u.sym, common["vtype"], - trace="forward", launch=self.stress_transport[len("forward_"):], + trace="forward", launch=_SEMI_LAGRANGIAN_TRANSPORTS[self.stress_transport][1], degree=common["degree"], varsymbol=common["varsymbol"], order=order, units=common["units"], ) @@ -3493,7 +3495,9 @@ class _SmoothingLengthMixin: ``_smoothing_is_dimensional`` flag that lets a dimensional input round-trip as a Pint Quantity while plain-float input round-trips as a plain float. Subclasses keep their own property docstrings as thin - wrappers delegating here. + wrappers delegating here. It also holds :meth:`linear_solver`, the switch + to a CG solve that every projection (scalar, vector, multi-component) can + take. """ def _set_smoothing(self, value): @@ -3540,6 +3544,53 @@ def _set_smoothing_length(self, L): self._smoothing_is_dimensional = is_dim self._smoothing = sympify(L_nd) ** 2 + def linear_solver(self, pc="jacobi", rtol=1.0e-10): + """Switch this projector to a lightweight *linear* (SPD) solve. + + An L2 projection (and the screened-Poisson smoother) is a **linear, + symmetric-positive-definite** problem, so the inherited + ``newtonls / gmres / gamg`` default is unnecessarily heavy — GAMG + setup/repartition dominates cost and memory at MPI scale, which is the + bottleneck for repeated post-processing projections (UW3 issue #156). + This replaces it with ``ksponly + CG + a cheap preconditioner`` — the + right tool for the mass/Helmholtz matrix — and removes the now-unused + GAMG options. + + Opt-in: the default ``SNES_Scalar`` solver stack is unchanged for code + that relies on it. Call this on a projector used purely for output / + post-processing. + + Parameters + ---------- + pc : str, default "jacobi" + Preconditioner. ``"jacobi"`` is fine for a well-conditioned mass + matrix; use ``"bjacobi"`` or ``"icc"`` if CG iteration counts climb + on distorted or high-degree meshes. + rtol : float, default 1e-10 + KSP relative tolerance. + + Returns + ------- + self (so the call can be chained). + """ + self.petsc_options["snes_type"] = "ksponly" + self.petsc_options["ksp_type"] = "cg" + self.petsc_options["pc_type"] = pc + self.petsc_options["ksp_rtol"] = rtol + # GAMG-specific options are now unused; remove them to avoid PETSc + # "unused option" warnings (and any stale AMG configuration). + for _k in ( + "pc_gamg_type", + "pc_gamg_repartition", + "pc_gamg_agg_nsmooths", + "pc_mg_type", + ): + try: + self.petsc_options.delValue(_k) + except Exception: + pass + return self + class SNES_Projection(_SmoothingLengthMixin, SNES_Scalar): r""" @@ -3691,53 +3742,6 @@ def __init__( # Use SymbolicProperty for automatic unwrapping uw_function = SymbolicProperty(matrix_wrap=True, doc="Function to project onto mesh") - def linear_solver(self, pc="jacobi", rtol=1.0e-10): - """Switch this projector to a lightweight *linear* (SPD) solve. - - An L2 projection (and the screened-Poisson smoother) is a **linear, - symmetric-positive-definite** problem, so the inherited - ``newtonls / gmres / gamg`` default is unnecessarily heavy — GAMG - setup/repartition dominates cost and memory at MPI scale, which is the - bottleneck for repeated post-processing projections (UW3 issue #156). - This replaces it with ``ksponly + CG + a cheap preconditioner`` — the - right tool for the mass/Helmholtz matrix — and removes the now-unused - GAMG options. - - Opt-in: the default ``SNES_Scalar`` solver stack is unchanged for code - that relies on it. Call this on a projector used purely for output / - post-processing. - - Parameters - ---------- - pc : str, default "jacobi" - Preconditioner. ``"jacobi"`` is fine for a well-conditioned mass - matrix; use ``"bjacobi"`` or ``"icc"`` if CG iteration counts climb - on distorted or high-degree meshes. - rtol : float, default 1e-10 - KSP relative tolerance. - - Returns - ------- - self (so the call can be chained). - """ - self.petsc_options["snes_type"] = "ksponly" - self.petsc_options["ksp_type"] = "cg" - self.petsc_options["pc_type"] = pc - self.petsc_options["ksp_rtol"] = rtol - # GAMG-specific options are now unused; remove them to avoid PETSc - # "unused option" warnings (and any stale AMG configuration). - for _k in ( - "pc_gamg_type", - "pc_gamg_repartition", - "pc_gamg_agg_nsmooths", - "pc_mg_type", - ): - try: - self.petsc_options.delValue(_k) - except Exception: - pass - return self - @property def smoothing(self): r"""Smoothing coefficient :math:`\alpha` of the screened-Poisson form. @@ -4562,7 +4566,7 @@ def __init__( ## at the various resolutions tested. if transport not in _SEMI_LAGRANGIAN_TRANSPORTS: - raise ValueError(f"transport must be one of {_SEMI_LAGRANGIAN_TRANSPORTS}, " + raise ValueError(f"transport must be one of {tuple(_SEMI_LAGRANGIAN_TRANSPORTS)}, " f"not {transport!r}") if DuDt is not None and transport != "backward_nodes": raise ValueError("transport chooses the DuDt the solver builds; it cannot " @@ -4593,7 +4597,7 @@ def __init__( # so only those that were asked for are passed on asked = {k: v for k, v in (("monotone_mode", monotone_mode), ("old_frame_traceback", old_frame_traceback)) if v} - trace, launch = transport.split("_", 1) + trace, launch = _SEMI_LAGRANGIAN_TRANSPORTS[transport] self.Unknowns.DuDt = uw.systems.ddt.SemiLagrangian( self.mesh, u_Field.sym, diff --git a/src/underworld3/utilities/cell_polynomial_projection.py b/src/underworld3/utilities/cell_polynomial_projection.py index e14560047..28169a655 100644 --- a/src/underworld3/utilities/cell_polynomial_projection.py +++ b/src/underworld3/utilities/cell_polynomial_projection.py @@ -64,10 +64,12 @@ def __init__(self, meshVar): self.ncells = self.detJ.shape[0] probe = tabulate(self.fe, np.zeros((1, self.dim))) self.Nb = probe.shape[1] // self.num_components # scalar basis size - # Reference-cell centroid, mapped: xi_c + 1 = 2 / (dim + 1) on every axis. + # Reference-cell centroid, mapped: xi_c + 1 = 2 / (dim + 1) on every axis + # of a simplex, 1 (the origin of [-1, 1]^dim) of a quadrilateral or hexahedron. J = np.linalg.inv(self.invJ) if self.ncells else self.invJ self.centroids = self.v0 + np.einsum( - "cij,j->ci", J, np.full(self.dim, 2.0 / (self.dim + 1)) + "cij,j->ci", J, + np.full(self.dim, 2.0 / (self.dim + 1) if mesh.isSimplex else 1.0) ) self._check_layout() # Reference coordinates of the cell's dof nodes (the same in every @@ -106,7 +108,9 @@ def reference_coords(self, coords, cells): """Reference coordinates (PETSc's [-1, 1] frame) of points in their cells.""" return np.einsum("cij,cj->ci", self.invJ[cells], coords - self.v0[cells]) - 1.0 - def containing_cells(self, coords, tol=1.0e-9): + FACE_TOLERANCE = 1.0e-9 + + def containing_cells(self, coords, tol=FACE_TOLERANCE): """Every local cell that contains each point. Returns ``(point, cell, lam)``: one row per (point, containing cell) @@ -131,14 +135,29 @@ def containing_cells(self, coords, tol=1.0e-9): point = np.repeat(np.arange(coords.shape[0]), k) cell = near.reshape(-1) xi = self.reference_coords(np.repeat(coords, k, axis=0), cell) + lam = self._reference_distance(xi) + inside = lam >= -tol + point, cell, lam = point[inside], cell[inside], lam[inside] + # a point whose containing cell is not among the nearest centroids (a + # large cell next to small ones): ask the locator + missed = np.setdiff1d(np.arange(coords.shape[0]), point) + if missed.size: + found = np.asarray(self.mesh._robust_owning_cells(coords[missed]), dtype=np.int64) + ok = found >= 0 + if ok.any(): + xm = self.reference_coords(coords[missed[ok]], found[ok]) + point = np.concatenate([point, missed[ok]]) + cell = np.concatenate([cell, found[ok]]) + lam = np.concatenate([lam, self._reference_distance(xm)]) + return point, cell, lam + + def _reference_distance(self, xi): + """How far inside its cell's nearest face a point is, in reference units.""" if self.mesh.isSimplex: # PETSc's reference simplex has vertices at -1 and +1 on each axis lam_axes = 0.5 * (xi + 1.0) - lam = np.minimum(lam_axes.min(axis=1), 1.0 - lam_axes.sum(axis=1)) - else: - lam = 0.5 * (1.0 - np.abs(xi).max(axis=1)) - inside = lam >= -tol - return point[inside], cell[inside], lam[inside] + return np.minimum(lam_axes.min(axis=1), 1.0 - lam_axes.sum(axis=1)) + return 0.5 * (1.0 - np.abs(xi).max(axis=1)) def locate(self, coords): """Owning local cell of each point (-1 when not on this rank) and its reference coordinates.""" @@ -222,6 +241,8 @@ def fit(self, coords, values, nmin=None, patch_nnn=None, old=None, cond_max=1.0e U[held] = np.asarray(old, dtype=np.float64).reshape(self.ncells, self.Nb, nc)[held] thin = np.nonzero(~dense & ~held)[0] self.n_thin = int(thin.shape[0]) + if cell_local and old is None: + raise ValueError("cell_local needs old: the value a cell nothing reached keeps") if cell_local and thin.shape[0] > 0: U[thin] = self._cell_linear_fit(thin, c, xi[ok], psi, cond_max) elif thin.shape[0] > 0 and c.shape[0] > 0: diff --git a/tests/parallel/test_1066_transport_schemes_mpi.py b/tests/parallel/test_1066_transport_schemes_mpi.py index 1e302347d..a02bb03ff 100644 --- a/tests/parallel/test_1066_transport_schemes_mpi.py +++ b/tests/parallel/test_1066_transport_schemes_mpi.py @@ -8,6 +8,12 @@ run must be the serial ones. At least three ranks: two ranks meet only along one seam, while three or more also meet at points, where an arrival can be handed to either of two other ranks. + +The value histories that apply an inflow value are given one, so the inflow +detection runs across seams too. At np 4 the backward integration-point stress +history has three departure points that the parallel evaluator used to strand +and fill by rbf extrapolation (7.5e-5 before the 2026-09-27 fix): this file is +the regression test for global_evaluate's containment round as well. """ import numpy as np import pytest @@ -51,6 +57,8 @@ def rotating_gaussian(transport, steps=16, dt=np.pi / 32): sympy.exp(-((x - 0.5) ** 2 + y ** 2) / (2 * 0.1 ** 2)), T.coords).reshape(-1) adv = uw.systems.AdvDiffusionSLCN(mesh, u_Field=T, V_fn=sympy.Matrix([[-y, x]]), order=1, transport=transport) + if adv.DuDt.applies_inflow_value: + adv.DuDt.inflow_value = sympy.Matrix([[0.0]]) adv.constitutive_model = uw.constitutive_models.DiffusionModel adv.constitutive_model.Parameters.diffusivity = 1.0e-3 for wall in ("Left", "Right", "Top", "Bottom"): @@ -63,16 +71,19 @@ def rotating_gaussian(transport, steps=16, dt=np.pi / 32): @pytest.mark.parametrize("transport", [ pytest.param(t, marks=pytest.mark.xfail( - uw.mpi.size >= 4, strict=True, reason="TODO(BUG): the particle history differs from serial by 1.2e-5 " - "at np >= 4 (np 3 matches); the particle values, their ownership and the cell " - "proxy's fit are partition-independent -- cause not yet found")) + uw.mpi.size >= 4, strict=False, reason="#797: the particle history differs from " + "serial by 1.2e-5 at np 4 and 6 (np 3 matches)")) if t == "lagrangian" else t for t in STRESS_XY]) def test_every_stress_history_gives_the_serial_stress_on_every_rank(transport): - _kind, values, _relocated = turned_over_maxwell_box(transport) + uw.reset_default_model() + _kind, values, relocated = turned_over_maxwell_box(transport) + if transport.startswith("forward_"): + assert relocated > 0 # the seams were crossed, so the exchange ran assert np.allclose(values, STRESS_XY[transport], atol=ATOL), (transport, values) @pytest.mark.parametrize("transport", list(ADVECTED_T)) def test_every_value_history_gives_the_serial_field_on_every_rank(transport): + uw.reset_default_model() values = rotating_gaussian(transport) assert np.allclose(values, ADVECTED_T[transport], atol=ATOL), (transport, values) diff --git a/tests/test_1063_stress_history_restart.py b/tests/test_1063_stress_history_restart.py index 2c6340712..543ab231e 100644 --- a/tests/test_1063_stress_history_restart.py +++ b/tests/test_1063_stress_history_restart.py @@ -42,7 +42,7 @@ def _stress_at_origin(stokes): return float(np.asarray(uw.function.evaluate(stokes.DFDt.psi_star[0].sym[0, 1], np.array([[0.0, 0.0]]))).reshape(-1)[0]) -@pytest.mark.parametrize("transport", ["backward_nodes", "backward_integration_points", "forward_integration_points", "lagrangian"]) +@pytest.mark.parametrize("transport", ["backward_nodes", "backward_integration_points", "forward_integration_points", "forward_nodes", "lagrangian"]) def test_a_restored_history_continues_where_it_left_off(transport): orchestration_model, stokes, dt = _shear_box(transport) for _ in range(6): diff --git a/tests/test_1102_forward_nodes_rotating_gaussian.py b/tests/test_1102_forward_nodes_rotating_gaussian.py index 650d92d84..91c16a455 100644 --- a/tests/test_1102_forward_nodes_rotating_gaussian.py +++ b/tests/test_1102_forward_nodes_rotating_gaussian.py @@ -41,7 +41,7 @@ def _run(mesh, walls): def test_forward_from_nodes_on_the_disc(): uw.reset_default_model() err, peak = _run(_disc(24), ("Upper",)) - assert abs(err - 0.01780) < 0.0018, err # BASELINE (2026-09-27) + assert abs(err - 0.017804) < 1.0e-4, err # BASELINE (2026-09-27) assert abs(peak - EXACT_PEAK) < 0.002, (peak, EXACT_PEAK) @@ -50,5 +50,5 @@ def test_forward_from_nodes_holds_the_box(): mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(-1.0, -1.0), maxCoords=(1.0, 1.0), cellSize=2.0 / 24, qdegree=3, regular=False) err, peak = _run(mesh, ("Left", "Right", "Top", "Bottom")) - assert abs(err - 0.0657) < 0.0065, err # BASELINE (2026-09-27) + assert abs(err - 0.065672) < 1.0e-4, err # BASELINE (2026-09-27) assert abs(peak - EXACT_PEAK) < 0.005, (peak, EXACT_PEAK) From 28314aa34b2b2962f1de0278cef9a71512677529 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sun, 27 Sep 2026 09:33:19 -0700 Subject: [PATCH 05/21] The monotone clamp is applied where a point is evaluated; the Navier-Stokes velocity history offers every semi-Lagrangian scheme global_evaluate applied monotone="clamp" on the rank that asked for a point, bounding it by that rank's nearest nodes; for a departure point evaluated on another rank those are the wrong neighbourhood. The bound now runs on the evaluating rank (first pass and containment round), on non-dimensional values like the nodal data it compares with. Serial results are unchanged. #682's level-set reproduction (LeVeque swirl, 128^2 quad, P2, SLCN with clamp): volume at np 1/2/4/8 now 0.0706771442/415/438/412 (np 8 was 1.6% off). NavierStokesSLCN(velocity_transport=...) chooses the velocity history among the four semi-Lagrangian schemes, through one _value_history helper shared with AdvDiffusionSLCN(transport=...). Forward schemes need order=1; the forward integration-point fit is linear and refuses a P2 velocity. tests: test_1103 (lid-driven cavity, Re 100, three schemes against recorded values and each other); test_1066 adds the cavity (equal to serial at np 3/4/6). tests/parallel at np 4: 168 passed, 1 xfailed, 1 failed (test_1063_constrained_freeslip_parallel[ti], fails identically on #795's 05d52480). Serial set: 393 passed. Underworld development team with AI support from Claude Code Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo --- docs/developer/subsystems/stress-transport.md | 9 ++ src/underworld3/function/_function.pyx | 7 ++ .../function/functions_unit_system.py | 27 +++-- src/underworld3/systems/solvers.py | 104 +++++++++--------- .../test_1066_transport_schemes_mpi.py | 16 ++- ...t_1103_navier_stokes_velocity_transport.py | 70 ++++++++++++ 6 files changed, 172 insertions(+), 61 deletions(-) create mode 100644 tests/test_1103_navier_stokes_velocity_transport.py diff --git a/docs/developer/subsystems/stress-transport.md b/docs/developer/subsystems/stress-transport.md index 543b8a96c..7f505cae3 100644 --- a/docs/developer/subsystems/stress-transport.md +++ b/docs/developer/subsystems/stress-transport.md @@ -86,6 +86,15 @@ make that so, and a new history has to respect them: - a point is never given to one of two cells by a tie-break: a forward arrival on a shared face is fitted in every cell that contains it, and a point the parallel evaluator strands is evaluated by the rank whose cell contains it. +- a monotone bound (`monotone_mode="clamp"`) is applied on the rank that + evaluates the point, among the point's own nodes: applied on the rank that + asked, it bounded a departure point on another rank by the wrong + neighbourhood (#682, 1.6% of a level set's volume at np 8). + +The same holds for the value histories of advection-diffusion +(`AdvDiffusionSLCN(transport=...)`) and for the Navier-Stokes velocity history +(`NavierStokesSLCN(velocity_transport=...)`; the forward integration-point fit +is linear, so it refuses a P2 velocity). The particle history differs from serial by about 1e-5 at np 4 and 6 (np 3 matches); the cause is open. diff --git a/src/underworld3/function/_function.pyx b/src/underworld3/function/_function.pyx index e35354b1c..67137c69d 100644 --- a/src/underworld3/function/_function.pyx +++ b/src/underworld3/function/_function.pyx @@ -370,6 +370,7 @@ def global_evaluate_nd( expr, force_l2=False, smoothing=1e-6, local_fallback=True, + limit=None, ): """ @@ -518,6 +519,10 @@ def global_evaluate_nd( expr, # sympy.simplify on every call for any expression holding a mesh variable # (14 of 25 s in a semi-Lagrangian step with a tanh velocity, 2026-09-08). values, extrapolated = evaluate_nd(expr, local_coords, rbf=rbf, evalf=evalf, verbose=verbose, check_extrapolated=True, simplify=simplify,) + # ``limit(coords, values)`` (a monotone bound) runs on the rank that + # evaluated the points, where their neighbourhood is local + if limit is not None and local_coords.shape[0] > 0: + values = limit(local_coords, values) if local_coords.shape[0] > 0: data_container.array[...] = values[...] @@ -677,6 +682,8 @@ def global_evaluate_nd( expr, fe_vals, _fe_flag = evaluate_nd( expr, np.ascontiguousarray(all_ext[mine]), rbf=rbf, evalf=evalf, verbose=False, simplify=simplify, check_extrapolated=True,) + if limit is not None and mine.any(): + fe_vals = limit(np.ascontiguousarray(all_ext[mine]), fe_vals) contrib_fe = np.zeros((n_ext_total,) + expr_shape, dtype=np.float64) if mine.any(): contrib_fe[mine] = np.asarray(fe_vals, dtype=np.float64).reshape((-1,) + expr_shape) diff --git a/src/underworld3/function/functions_unit_system.py b/src/underworld3/function/functions_unit_system.py index a92926bf8..7f5930a7e 100644 --- a/src/underworld3/function/functions_unit_system.py +++ b/src/underworld3/function/functions_unit_system.py @@ -361,6 +361,7 @@ def _global_evaluate_impl( rbf=None, force_l2=None, local_fallback=True, + limit=None, ): """ Global evaluate with automatic unit-aware results. @@ -542,6 +543,7 @@ def _global_evaluate_impl( force_l2=force_l2_flag, smoothing=smoothing, local_fallback=local_fallback, + limit=limit, ) # Step 2: Re-dimensionalize and wrap with units (GATEWAY PRINCIPLE) @@ -692,12 +694,11 @@ def _apply_monotone_limit( psi_coords_nd = np.asarray(psi_coords_nd.magnitude) # --- kNN neighbour stats from the source nodal data ------------------ - # TODO(parallel): the KDTree is built from rank-local `var.coords_nd`, - # so near a partition seam the neighbour stats bound against a - # truncated neighbourhood. This matches the validated SL behaviour. For - # full parallel correctness the bound should include halo / global DOF - # neighbours -- see the nav-only overlap-clone machinery - # (project_parallel_point_eval_decision) as the hook if hardened. + # The KDTree is built from rank-local `var.coords_nd`. global_evaluate + # calls this on the rank that evaluated each point, so the point's own + # cell and its nodes are local; a point's nearest nodes can still lie in a + # cell the rank does not hold only when it sits against a partition seam. + # TODO(parallel): a halo of the neighbouring cells' nodes would close that. # TODO(units): nbr bounds come from `var.data` (always non-dimensional) # while `value` is dimensional in a units-active run -- a pre-existing # latent mismatch (scaling is inactive in the validated baseline so it @@ -1009,6 +1010,17 @@ def global_evaluate( "check_extrapolated in global_evaluate." ) + # The clamp bounds a value by the nodal data around the point, so it is + # applied where the point is evaluated -- on the rank whose cells hold it, + # among that point's own nodes -- not on the rank that asked, whose nodes + # near a departure point on another rank are the wrong neighbourhood + # (#682: 1.4% of a level set's volume at np 8). The values there are + # non-dimensional, as the nodal data are. + limit = None + if monotone_mode == "clamp": + def limit(local_coords, values): + return _apply_monotone_limit(expr, local_coords, values, "clamp") + result = _global_evaluate_impl( expr, coords=coords, @@ -1024,9 +1036,10 @@ def global_evaluate( rbf=rbf, force_l2=force_l2, local_fallback=local_fallback, + limit=limit, ) - if monotone_mode is None: + if monotone_mode is None or limit is not None: return result return _apply_monotone_limit( diff --git a/src/underworld3/systems/solvers.py b/src/underworld3/systems/solvers.py index 0c6cb931d..a16ba146f 100644 --- a/src/underworld3/systems/solvers.py +++ b/src/underworld3/systems/solvers.py @@ -469,6 +469,35 @@ def _invalidate_solution_cache(u): "forward_integration_points": ("forward", "integration_points"), "forward_nodes": ("forward", "nodes"), } +def _value_history(transport, mesh, field, V_fn, vtype, order, nodal_options, requested, + **common): + """The semi-Lagrangian history a solver builds for the value of ``field``. + + ``transport`` names the scheme (a key of ``_SEMI_LAGRANGIAN_TRANSPORTS``). + ``nodal_options`` go to the backward nodal scheme only (its boundary + conditions, smoothing, ...); ``requested`` are options the user set, which + go to whichever scheme is chosen and are refused by one that does not + take them; ``common`` go to every scheme. + """ + if transport not in _SEMI_LAGRANGIAN_TRANSPORTS: + raise ValueError(f"transport must be one of {tuple(_SEMI_LAGRANGIAN_TRANSPORTS)}, " + f"not {transport!r}") + if transport == "backward_nodes": + return BackwardNodesSemiLagrangian( + mesh, field.sym, V_fn, vtype=vtype, degree=field.degree, + continuous=field.continuous, varsymbol=field.symbol, order=order, + **nodal_options, **requested, **common) + if not field.continuous: + raise NotImplementedError( + f"transport={transport!r} holds a continuous history; " + "use transport='backward_nodes' for a discontinuous field") + trace, launch = _SEMI_LAGRANGIAN_TRANSPORTS[transport] + return uw.systems.ddt.SemiLagrangian( + mesh, field.sym, V_fn, vtype, trace=trace, launch=launch, + degree=field.degree, varsymbol=field.symbol, order=order, + **requested, **common) + + _RENAMED_TRANSPORTS = { "semi_lagrangian": "backward_nodes", "integration_point": "backward_integration_points", @@ -4565,51 +4594,16 @@ def __init__( ## NB - Smoothing is generally required for stability. 0.0001 is effective ## at the various resolutions tested. - if transport not in _SEMI_LAGRANGIAN_TRANSPORTS: - raise ValueError(f"transport must be one of {tuple(_SEMI_LAGRANGIAN_TRANSPORTS)}, " - f"not {transport!r}") if DuDt is not None and transport != "backward_nodes": raise ValueError("transport chooses the DuDt the solver builds; it cannot " "apply to a DuDt that is supplied") - if DuDt is None and transport == "backward_nodes": - self.Unknowns.DuDt = BackwardNodesSemiLagrangian( - self.mesh, - u_Field.sym, # Symbolic expression - SemiLagrangian evaluates this at each update - self._V_fn, - vtype=uw.VarType.SCALAR, - degree=u_Field.degree, - continuous=u_Field.continuous, - varsymbol=u_Field.symbol, - verbose=verbose, - bcs=self.essential_bcs, - order=1, - smoothing=0.0, - monotone_mode=monotone_mode, - theta=theta, - old_frame_traceback=old_frame_traceback, - ) - elif DuDt is None: - if not u_Field.continuous: - raise NotImplementedError( - f"transport={transport!r} holds a continuous history; " - "use transport='backward_nodes' for a discontinuous field") - # options a scheme does not take are refused by ddt.SemiLagrangian, - # so only those that were asked for are passed on - asked = {k: v for k, v in (("monotone_mode", monotone_mode), - ("old_frame_traceback", old_frame_traceback)) if v} - trace, launch = _SEMI_LAGRANGIAN_TRANSPORTS[transport] - self.Unknowns.DuDt = uw.systems.ddt.SemiLagrangian( - self.mesh, - u_Field.sym, - self._V_fn, - uw.VarType.SCALAR, - trace=trace, - launch=launch, - degree=u_Field.degree, - varsymbol=u_Field.symbol, - order=1, + if DuDt is None: + self.Unknowns.DuDt = _value_history( + transport, self.mesh, u_Field, self._V_fn, uw.VarType.SCALAR, order=1, + nodal_options=dict(verbose=verbose, bcs=self.essential_bcs, smoothing=0.0), + requested={k: v for k, v in (("monotone_mode", monotone_mode), + ("old_frame_traceback", old_frame_traceback)) if v}, theta=theta, - **asked, ) else: @@ -5452,6 +5446,14 @@ class SNES_NavierStokes(SNES_Stokes_SaddlePt): Time derivative operator for velocity. DFDt : BackwardNodesSemiLagrangian or Lagrangian_DDt, optional Time derivative operator for stress. + velocity_transport : str, default="backward_nodes" + The semi-Lagrangian scheme of the internally-constructed velocity + history: ``"backward_nodes"``, ``"backward_integration_points"``, + ``"forward_integration_points"`` or ``"forward_nodes"``, named by the + ``trace`` and ``launch`` arguments of + :func:`~underworld3.systems.ddt.SemiLagrangian`. The forward schemes + carry one level, so they need ``order=1``; the viscoelastic stress + history is chosen separately, by :attr:`stress_transport`. Notes ----- @@ -5498,6 +5500,7 @@ def __init__( verbose: Optional[bool] = False, DuDt: Union[BackwardNodesSemiLagrangian, Lagrangian_DDt] = None, DFDt: Union[BackwardNodesSemiLagrangian, Lagrangian_DDt] = None, + velocity_transport: str = "backward_nodes", ): ## Parent class will set up default values and load u_Field into the solver super().__init__( @@ -5534,20 +5537,15 @@ def __init__( ### sets up DuDt and DFDt ## ._setup_history_terms() - # If DuDt is not provided, then we can build a SLCN version + if DuDt is not None and velocity_transport != "backward_nodes": + raise ValueError("velocity_transport chooses the DuDt the solver builds; it " + "cannot apply to a DuDt that is supplied") if self.Unknowns.DuDt is None: - self.Unknowns.DuDt = uw.systems.ddt.SemiLagrangian( - self.mesh, - self.u.sym, # Symbolic expression - SemiLagrangian evaluates this at each update - self.u.sym, - vtype=uw.VarType.VECTOR, - degree=self.u.degree, - continuous=self.u.continuous, - varsymbol=self.u.symbol, - verbose=self.verbose, - bcs=self.essential_bcs, + self.Unknowns.DuDt = _value_history( + velocity_transport, self.mesh, self.u, self.u.sym, uw.VarType.VECTOR, order=self._order, - smoothing=0.0001, + nodal_options=dict(verbose=self.verbose, bcs=self.essential_bcs, smoothing=0.0001), + requested={}, ) # F (at least for N-S) is a nodal point variable so there is no benefit diff --git a/tests/parallel/test_1066_transport_schemes_mpi.py b/tests/parallel/test_1066_transport_schemes_mpi.py index a02bb03ff..cbd2c58e1 100644 --- a/tests/parallel/test_1066_transport_schemes_mpi.py +++ b/tests/parallel/test_1066_transport_schemes_mpi.py @@ -4,7 +4,8 @@ two counter-rotating cells between no-slip walls, an unstructured mesh), so the stress is non-uniform and the flow crosses seams of either orientation. Advection: a rotating Gaussian in a square box (the flow crosses every wall), -P2, a quarter turn, with each of the four semi-Lagrangian value histories. The values at two points after the +P2, a quarter turn, with each of the four semi-Lagrangian value histories. +Momentum: the lid-driven cavity of test_1103, with each velocity history. The values at two points after the run must be the serial ones. At least three ranks: two ranks meet only along one seam, while three or more also meet at points, where an arrival can be handed to either of two other ranks. @@ -19,9 +20,15 @@ import pytest import sympy +import sys +from pathlib import Path + import underworld3 as uw from test_1062_forward_stress_history_mpi import turned_over_maxwell_box +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from test_1103_navier_stokes_velocity_transport import CAVITY_U, lid_driven_cavity # noqa: E402 + pytestmark = [pytest.mark.level_2, pytest.mark.tier_b, pytest.mark.mpi(min_size=3), pytest.mark.timeout(1200)] # BASELINES: the serial values at the points of each test (2026-09-27) @@ -87,3 +94,10 @@ def test_every_value_history_gives_the_serial_field_on_every_rank(transport): uw.reset_default_model() values = rotating_gaussian(transport) assert np.allclose(values, ADVECTED_T[transport], atol=ATOL), (transport, values) + + +@pytest.mark.parametrize("transport", list(CAVITY_U)) +def test_every_velocity_history_gives_the_serial_flow_on_every_rank(transport): + uw.reset_default_model() + _kind, values = lid_driven_cavity(transport) + assert np.allclose(values, CAVITY_U[transport], atol=ATOL), (transport, values) diff --git a/tests/test_1103_navier_stokes_velocity_transport.py b/tests/test_1103_navier_stokes_velocity_transport.py new file mode 100644 index 000000000..e3884b5bc --- /dev/null +++ b/tests/test_1103_navier_stokes_velocity_transport.py @@ -0,0 +1,70 @@ +"""The Navier-Stokes velocity history carried by each semi-Lagrangian scheme. + +A lid-driven cavity at Reynolds number 100 (lid speed 1, viscosity 0.01, +density 1), first order, ten steps of 0.05 from rest: the momentum is advected, +so the velocity history is doing work. There is no closed form; each scheme is +held to its own recorded value. The forward integration-point history fits a +linear polynomial per cell and refuses the P2 velocity. +""" + +import numpy as np +import pytest +import sympy + +import underworld3 as uw + +pytestmark = [pytest.mark.level_2, pytest.mark.tier_b] + +POINTS = np.array([[0.5, 0.75], [0.3, 0.5]]) +# BASELINES: horizontal velocity at POINTS after ten steps (2026-09-27) +CAVITY_U = { + "backward_nodes": (-0.1302979, -0.0553993), + "backward_integration_points": (-0.1300480, -0.0554152), + "forward_nodes": (-0.1300408, -0.0554839), +} + + +def lid_driven_cavity(transport, steps=10, dt=0.05, cell_size=1.0 / 12): + mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), + cellSize=cell_size, qdegree=3, regular=False) + v = uw.discretisation.MeshVariable("U_cav", mesh, mesh.dim, degree=2) + p = uw.discretisation.MeshVariable("P_cav", mesh, 1, degree=1) + ns = uw.systems.NavierStokesSLCN(mesh, velocityField=v, pressureField=p, rho=1.0, order=1, + velocity_transport=transport) + ns.constitutive_model = uw.constitutive_models.ViscousFlowModel + ns.constitutive_model.Parameters.shear_viscosity_0 = 0.01 + ns.add_dirichlet_bc((1.0, 0.0), "Top") + for wall in ("Bottom", "Left", "Right"): + ns.add_dirichlet_bc((0.0, 0.0), wall) + ns.bodyforce = sympy.Matrix([[0.0, 0.0]]) + ns.tolerance = 1.0e-8 + for _ in range(steps): + ns.solve(timestep=dt) + values = np.asarray(uw.function.global_evaluate(v.sym[0], POINTS)).reshape(-1) + return type(ns.DuDt).__name__, values + + +@pytest.mark.parametrize("transport", list(CAVITY_U)) +def test_each_velocity_history_gives_its_recorded_cavity_flow(transport): + uw.reset_default_model() + kind, values = lid_driven_cavity(transport) + assert kind == "".join(w.capitalize() for w in transport.split("_")) + "SemiLagrangian" + assert np.allclose(values, CAVITY_U[transport], atol=1.0e-6), (transport, values) + # the schemes differ by their transport error, a few parts in a thousand here + assert np.allclose(values, CAVITY_U["backward_nodes"], rtol=5.0e-3), (transport, values) + + +def test_the_forward_integration_point_fit_refuses_a_p2_velocity(): + uw.reset_default_model() + with pytest.raises(NotImplementedError, match="degree must be 1"): + lid_driven_cavity("forward_integration_points", steps=0) + + +def test_the_forward_schemes_refuse_order_two(): + uw.reset_default_model() + mesh = uw.meshing.UnstructuredSimplexBox(cellSize=0.25) + v = uw.discretisation.MeshVariable("U_o2", mesh, mesh.dim, degree=2) + p = uw.discretisation.MeshVariable("P_o2", mesh, 1, degree=1) + with pytest.raises(NotImplementedError, match="order must be 1"): + uw.systems.NavierStokesSLCN(mesh, velocityField=v, pressureField=p, order=2, + velocity_transport="forward_nodes") From 2adf50d039d5825160e83009f1cbc49d712b60d6 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Mon, 28 Sep 2026 12:41:50 -0700 Subject: [PATCH 06/21] SUPG sees the carried elastic stress; the semi-Lagrangian Navier-Stokes solver takes any stress history and the log-conformation store uw.systems.NavierStokes: the strong residual SUPG weights now includes the divergence of the stress the history carries (_memory_stress: the model's effective strain rate with the velocity's own derivatives set to zero, times 2 eta, plus the theta rule's stored levels; decoded for a log store). An integration-point store has no derivative; its nodal snapshot stands in, an O(dt) difference. On a developing Oldroyd-B channel (Re 250, Wi 0.6) the SUPG solution moved toward the Galerkin one on the same mesh: velocity 5.0e-5 -> 3.2e-5, stress 3.4e-6 -> 1.6e-6. (Fully developed channel flow is blind to this: the missing residual is constant along streamlines.) NavierStokesSLCN: - stress_transport, the stress-history factory and its prepare/carry/commit steps move from SNES_Stokes into _StressHistoryMixin, shared by both; the solver used to ignore stress_transport (a plain attribute) and always carry a backward nodal history, recorded a step late (#742). - the momentum flux's theta rule reads the carried stress through the model (a log-conformation store decodes) and adds the solvent stress of the carried velocity; the solvent stress was missing from its momentum flux. - its time order is the momentum's (_momentum_order); the stress history's order is the constitutive model's (the solver's order used to be forced on the model). - a supplied DFDt is used (it was overwritten); the viscous-flux history is built only for a model without a stress history. tests: test_1104 (SUPG memory stress: structure, snapshot stand-in, recorded developing channel); test_1059 runs every stress transport and the log store (N1 of upper-convected start-up 1.193 against 1.188 exact; the linear stress store gives 1.108) through NavierStokesSLCN. Serial set 403 passed (+ the test_1060 fix); parallel np 3/6 transport tests pass; tests/parallel at np 4 as before (#801 pre-existing, #797 xfail). Underworld development team with AI support from Claude Code Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo --- docs/developer/subsystems/stress-transport.md | 21 + .../systems/navier_stokes_eulerian.py | 59 +- src/underworld3/systems/solvers.py | 674 ++++++++++-------- tests/test_1059_stress_transport.py | 37 +- ...t_1104_navier_stokes_supg_memory_stress.py | 98 +++ 5 files changed, 562 insertions(+), 327 deletions(-) create mode 100644 tests/test_1104_navier_stokes_supg_memory_stress.py diff --git a/docs/developer/subsystems/stress-transport.md b/docs/developer/subsystems/stress-transport.md index 7f505cae3..afbade12b 100644 --- a/docs/developer/subsystems/stress-transport.md +++ b/docs/developer/subsystems/stress-transport.md @@ -96,6 +96,27 @@ The same holds for the value histories of advection-diffusion (`NavierStokesSLCN(velocity_transport=...)`; the forward integration-point fit is linear, so it refuses a P2 velocity). +## With inertia + +Both Navier-Stokes solvers take `stress_transport` and carry the stress history +through the same three steps as the Stokes family (prepare, carry once per +step, commit after the solve), and both read a log-conformation store through +the model's decode. + +- `uw.systems.NavierStokes` transports momentum on the grid with SUPG. The + SUPG term weights the strong momentum residual, and that residual includes + the divergence of the stress the history carries (at high Weissenberg number + the largest term of the balance). The integration-point store has no + derivative, so `backward_integration_points` is refused here; the viscous + term and the terms that carry the velocity gradient itself (the objective + rate's) need second derivatives and remain outside the residual. +- `uw.systems.NavierStokesSLCN` carries the velocity semi-Lagrangianly + (`velocity_transport=`) and applies the theta rule to the momentum flux: the + new stress, and at each stored level the carried stress plus the solvent + stress of the carried velocity. Its time order is the momentum's; the stress + history's order is the constitutive model's. + + The particle history differs from serial by about 1e-5 at np 4 and 6 (np 3 matches); the cause is open. diff --git a/src/underworld3/systems/navier_stokes_eulerian.py b/src/underworld3/systems/navier_stokes_eulerian.py index c45a6dc7d..391a2a3e1 100644 --- a/src/underworld3/systems/navier_stokes_eulerian.py +++ b/src/underworld3/systems/navier_stokes_eulerian.py @@ -385,9 +385,12 @@ def _strong_residual(self, with_pressure=False): :math:`-p\mathbf{I}` in :math:`\mathbf{F}_1`, so it must not appear in :math:`\mathbf{f}_0`, but a strong residual without it is O(1) at the exact solution and the stabilisation then injects an O(tau) error - (measured on Kovasznay flow: 50 times the Galerkin error). The - viscous term needs second derivatives the kernels do not see; it is - the remaining inconsistency for P2 velocity. + (measured on Kovasznay flow: 50 times the Galerkin error). For the + same reason it takes the divergence of the stress a viscoelastic + history carries (:meth:`_memory_stress`): at high Weissenberg number + that stress dominates the momentum balance. The viscous term needs + second derivatives the kernels do not see; it is the remaining + inconsistency for P2 velocity. """ # The body-force setter may store a column; the residual is a row. dim = self.mesh.dim @@ -396,8 +399,58 @@ def _strong_residual(self, with_pressure=False): if with_pressure: X = self.mesh.X R = R + sympy.Matrix([[self.p.sym[0].diff(X[i]) for i in range(dim)]]) + memory = self._memory_stress() + if memory is not None: + R = R - sympy.Matrix([[sum(memory[i, j].diff(X[j]) for j in range(dim)) + for i in range(dim)]]) return R + def _memory_stress(self): + r"""The part of the momentum flux the stress history carries, or ``None``. + + The model's flux is :math:`2\eta\,\dot\varepsilon_\mathrm{eff}`, and the + effective strain rate is the velocity's own strain rate plus the terms + of the history (the carried stress, decoded from a log-conformation + store, and a stored strain rate for the exponential integrator). With + the velocity's derivatives set to zero, what remains is the history's + part, whichever integrator made it; the stored levels of the theta rule + are history too. Its divergence needs only first derivatives of the + stores (of an integration-point store's nodal snapshot). Terms that carry the velocity gradient itself (the objective + rate's, the deformation step's) go with the viscous term: their + divergence needs second derivatives of the velocity, as does that of a + yielding material's strain-rate-dependent viscosity. + """ + history = self.Unknowns.DFDt + cm = self.constitutive_model + if history is None or not getattr(cm, "is_elastic", False): + return None + # TODO(DESIGN): a yielding material's viscosity depends on the strain + # rate; its divergence needs second derivatives the kernels do not see. + X = self.mesh.X + dim = self.mesh.dim + own_rate = {self.u.sym[i].diff(X[j]): 0 for i in range(dim) for j in range(dim)} + memory = 2 * cm.viscosity * sympy.Matrix(cm.E_eff.sym).xreplace(own_rate) + weights = self.DuDt.spatial_weights() + memory = weights[0] * memory + for level, w in enumerate(weights[1:]): + if w == 0: + continue + memory = memory + w * sympy.Matrix( + cm._carried_stress_sym(level) if hasattr(cm, "_carried_stress_sym") + else history.psi_star[level].sym) + # An integration-point store has no derivative. Its values are the nodal + # snapshot of the committed stress sampled at the departure points, so + # the snapshot stands in for it here: the divergence of the stress where + # it was rather than where it has been carried, an O(dt) difference. + stand_in = {} + for store, snapshot in ((getattr(history, "psi_star", []), getattr(history, "psi_snap", None)), + ([history.forcing_star] if getattr(history, "forcing_star", None) is not None + else [], [getattr(history, "forcing_snap", None)])): + for level, star in enumerate(store): + if getattr(star, "is_integration_point", False): + stand_in.update(zip(sympy.Matrix(star.sym), sympy.Matrix(snapshot[level].sym))) + return memory.xreplace(stand_in) if stand_in else memory + def _viscous_stress(self, u_row): r"""Deviatoric stress ``2 eta strain(u)`` for a velocity row, with the current effective viscosity of the constitutive model.""" diff --git a/src/underworld3/systems/solvers.py b/src/underworld3/systems/solvers.py index a16ba146f..d2806288b 100644 --- a/src/underworld3/systems/solvers.py +++ b/src/underworld3/systems/solvers.py @@ -505,6 +505,291 @@ def _value_history(transport, mesh, field, V_fn, vtype, order, nodal_options, re } +class _StressHistoryMixin: + """The stress history of a momentum solver with a viscoelastic material. + + The choice of scheme (:attr:`stress_transport`), the history it builds + when a constitutive model that needs one is assigned, and the three steps + of its life in a timestep: prepare (before the build), carry (once per + step, before the solve) and commit (after it). Shared by the Stokes family + and the semi-Lagrangian Navier-Stokes solver, so each carries, commits and + reads the stress the same way. + """ + + _prev_effective_order = None + + def _devss_refresh(self, verbose=False): + """Refresh a history stabilisation; a solver without one has nothing to do.""" + + @property + def stress_transport(self) -> str: + """How a viscoelastic stress history is carried: ``"backward_nodes"`` + (default), ``"backward_integration_points"``, + ``"forward_integration_points"``, ``"forward_nodes"``, ``"lagrangian"`` + or ``"eulerian"``. + + The first four are the semi-Lagrangian schemes of + :func:`~underworld3.systems.ddt.SemiLagrangian`, named by the direction + of the trace and the points the history is held at. A backward trace + follows the characteristic back from each storage point and samples the + old stress at the departure point. ``"backward_nodes"`` stores the + history on a nodal field, which the assembler then interpolates to the + integration points: two interpolations a step. + ``"backward_integration_points"`` traces back to the integration points + themselves and holds the history there: one evaluation error and no + projection. A forward trace launches the old stress from where it is + known, carries it one step forward and fits the arrivals in each cell. + ``"forward_integration_points"`` launches from the integration points, + where the stress is formed, and reads the constitutive flux there + through a continuous P1 projection; it holds the Maxwell start-up below + Courant one where the backward integration-point history rings (see + :class:`~underworld3.systems.ddt.ForwardIntegrationPointsSemiLagrangian`). + ``"forward_nodes"`` launches the stress projected onto the continuous + history space from its nodes and from a lattice inside each element (see + :class:`~underworld3.systems.ddt.ForwardNodesSemiLagrangian`). + + ``"eulerian"`` transports the stress on the grid with the same + streamline-upwind stabilisation the Eulerian solvers use, and gives the + same answer on any partition. ``"lagrangian"`` carries the stress on a + swarm of material points the solver creates and advects, reading the + constitutive flux at the particles each step and never projecting it + back to the mesh: no numerical diffusion of the history, at the cost of + the swarm (see :class:`~underworld3.systems.ddt.Lagrangian`). + + Set it before the constitutive model is assigned: assigning the model + creates the history, and the choice cannot change after that. The + former names ``"semi_lagrangian"``, ``"integration_point"`` and + ``"forward"`` are accepted, with a warning. + """ + return getattr(self, "_stress_transport", "backward_nodes") + + @stress_transport.setter + def stress_transport(self, value): + value = str(value) + if value in _RENAMED_TRANSPORTS: + warnings.warn( + f"stress_transport={value!r} is now {_RENAMED_TRANSPORTS[value]!r}", + FutureWarning, stacklevel=2) + value = _RENAMED_TRANSPORTS[value] + if value not in (*_SEMI_LAGRANGIAN_TRANSPORTS, "lagrangian", "eulerian"): + raise ValueError( + f"stress_transport must be one of {tuple(_SEMI_LAGRANGIAN_TRANSPORTS)} or " + f"'lagrangian' or 'eulerian', not {value!r}.") + if self.Unknowns.DFDt is not None: + raise RuntimeError( + "the stress history already exists: set stress_transport before the " + "constitutive model that asks for one.") + self._stress_transport = value + + def _stress_history_prepare(self, timestep, _force_setup=False): + """Set the elastic timestep and the flags a rebuild depends on. + + Runs BEFORE the solver is built. The effective order of the stress + history ramps over the opening steps, and when it changes the compiled + functions must be rewired; setting that flag after the build has already + decided whether to set up leaves the managed multigrid block asking a + preconditioner for sub-solvers it has not created (#727). + """ + # dt_elastic must always equal the solve timestep. The constitutive + # model's VE formulas (eta_eff, stress history terms) all reference + # Parameters.dt_elastic. If it differs from the actual timestep, + # the stress computation is inconsistent with the time integration. + self.constitutive_model.Parameters.dt_elastic = timestep + # The integrator coefficients must be current BEFORE the history is + # first carried: a trace-back history initialises its first level from + # the constitutive flux of the velocity it finds, and with the + # exponential integrator that flux read the viscous-limit coefficients + # (alpha = phi = 0) until the update that used to follow the carry + # (#740). The BDF coefficients are refreshed again after the carry, as + # before, since they read the step history the carry updates. + self.constitutive_model._update_history_coefficients() + + if _force_setup: + self._needs_function_rewire = True + + # Re-setup when effective_order changes (DDt history ramp-up) + _current_eff_order = self.constitutive_model.effective_order + if _current_eff_order != self._prev_effective_order: + self._needs_function_rewire = True + self.constitutive_model._solver_is_setup = False + self._prev_effective_order = _current_eff_order + + if not self.constitutive_model._solver_is_setup: + self._needs_function_rewire = True + self.DFDt.psi_fn = _history_psi_fn(self.constitutive_model) + # D starts from the velocity as it is now, so the DEVSS pair + # cancels on the first step as it does on every later one. + self._devss_refresh() + + def _stress_history_advance(self, timestep, verbose=False, evalf=False): + """Carry the stress history to where the momentum solve will read it. + + Runs AFTER the solver is built, and once per step: a solver that takes + several passes over the momentum equation (the Navier-Stokes one, with + its Picard corrections) must not advance the history once per pass. + """ + if uw.mpi.rank == 0 and verbose: + print("Stokes solver - carry the stress history", flush=True) + + self.DFDt.update_pre_solve(timestep, verbose=verbose, evalf=evalf, + store_result=False) + # Uniform pre-solve coefficient hook: VEP delegates to + # _update_bdf_coefficients(); MaxwellExponentialFlowModel updates + # α, φ on the DDt via _update_exp_coefficients(). No isinstance + # checks at the solver layer. + self.constitutive_model._update_history_coefficients() + + def _stress_history_post_solve(self, timestep, verbose=False, evalf=False): + """Commit the stress the solve produced and shift the history levels.""" + # The history manager places the new stress in level 0 and shifts the + # levels. A particle-carried history does that itself in its post-solve, + # by evaluating the new stress at its own particles. + if not self.DFDt.commits_flux_in_post_solve: + self.DFDt.commit_flux_to_history(_history_record(self.constitutive_model), verbose=verbose) + + self.DFDt.update_post_solve(timestep, verbose=verbose, evalf=evalf) + self._devss_refresh(verbose=verbose) + + # Uniform post-solve hook for any extra integrator-state storage. + # VEP: no-op. ETD-2 / MaxwellExponentialFlowModel: refresh + # forcing_star with current ε̇^{n+1} so the next step's history + # term has access to ε̇ⁿ. + self.constitutive_model._update_history_post_solve() + + self.is_setup = True + self.constitutive_model._solver_is_setup = True + + def _create_stress_history_ddt(self, order=2): + """Create DFDt for stress history tracking (VE/VEP models). + + Called automatically when a constitutive model with + ``requires_stress_history = True`` is assigned. Can also be called + explicitly to pre-create the DFDt with a specific order. + :attr:`stress_transport` chooses which flavour carries it. + + Constitutive models can inject extra SemiLagrangian kwargs via the + ``stress_history_ddt_kwargs`` property — used e.g. by + ``MaxwellExponentialFlowModel`` to set ``with_forcing_history=True``. + """ + if self.Unknowns.DFDt is not None: + return # already created + + self._order = order + # Constitutive model may request extra SemiLagrangian kwargs (e.g. + # with_forcing_history for ETD-2 integration). + cm = getattr(self, "constitutive_model", None) + ddt_kwargs = {} + if cm is not None: + ddt_kwargs = dict(getattr(cm, "stress_history_ddt_kwargs", {})) + + common = dict( + vtype=uw.VarType.SYM_TENSOR, + degree=self.u.degree - 1, + continuous=True, + varsymbol=rf"{{F[ {self.u.symbol} ] }}", + verbose=self.verbose, + bcs=None, + order=order, + smoothing=0.0001, + # the history carries a stress (the flux handed over at construction + # is a zero placeholder, so it cannot say so itself), or the + # dimensionless log-conformation of one + units=uw.units.Pa if getattr(cm, "_stress_history", "stress") == "stress" else None, + ) + if self.stress_transport == "backward_integration_points": + unsupported = set(ddt_kwargs) - {"with_forcing_history"} + if unsupported: + raise NotImplementedError( + f"{type(cm).__name__} asks its stress history for " + f"{sorted(unsupported)}, which the integration-point flavour " + "does not provide; use stress_transport='backward_nodes'.") + self.Unknowns.DFDt = uw.systems.ddt.BackwardIntegrationPointsSemiLagrangian( + self.mesh, + sympy.Matrix.zeros(self.mesh.dim, self.mesh.dim), + self.u.sym, + **ddt_kwargs, + **{k: v for k, v in common.items() if k != "smoothing"}, + ) + elif _SEMI_LAGRANGIAN_TRANSPORTS.get(self.stress_transport, ("",))[0] == "forward": + if ddt_kwargs: + raise NotImplementedError( + f"{type(cm).__name__} asks its stress history for " + f"{sorted(ddt_kwargs)}, which the forward flavours do not provide; " + "use stress_transport='backward_nodes' for it.") + self.Unknowns.DFDt = uw.systems.ddt.SemiLagrangian( + self.mesh, + sympy.Matrix.zeros(self.mesh.dim, self.mesh.dim), + self.u.sym, + common["vtype"], + trace="forward", launch=_SEMI_LAGRANGIAN_TRANSPORTS[self.stress_transport][1], + degree=common["degree"], varsymbol=common["varsymbol"], + order=order, units=common["units"], + ) + elif self.stress_transport == "lagrangian": + if ddt_kwargs: + raise NotImplementedError( + f"{type(cm).__name__} asks its stress history for " + f"{sorted(ddt_kwargs)}, which the particle Lagrangian flavour does " + "not provide; use stress_transport='backward_nodes' for it.") + # Order 1 BDF only for now: the particle flavour has no exponential + # coefficients (it is built with_exp=False), and order 2 is not yet + # validated. Refuse cleanly rather than crash inside the first solve. + if getattr(cm, "_integrator", "bdf") != "bdf": + raise NotImplementedError( + "the particle Lagrangian stress history supports the BDF " + "integrator only; use stress_transport='backward_nodes' for the " + "exponential one.") + if order > 1: + raise NotImplementedError( + "the particle Lagrangian stress history is first order for now; " + "use stress_transport='backward_nodes' for order 2.") + # The solver owns the swarm: Lagrangian creates and populates it, and + # carries the stress on it. Lagrangian_Swarm (a user-supplied swarm) + # stays available by passing DFDt= to the constructor. + # A cells proxy (discontinuous, reconstructed from the particles in + # each cell) is what the particle history is validated on + # (test_0070); the continuous nodal store the mesh flavours use is + # not the right target for a swarm-carried field. + lag_common = {k: v for k, v in common.items() if k != "continuous"} + self.Unknowns.DFDt = uw.systems.ddt.Lagrangian( + self.mesh, + sympy.Matrix.zeros(self.mesh.dim, self.mesh.dim), + self.u.sym, + continuous=False, + proxy_location="cells", + **lag_common, + ) + elif self.stress_transport == "eulerian": + if ddt_kwargs: + raise NotImplementedError( + f"{type(cm).__name__} asks its stress history for " + f"{sorted(ddt_kwargs)}, which only the semi-Lagrangian flavour " + "provides; use stress_transport='backward_nodes' for it.") + self.Unknowns.DFDt = uw.systems.ddt.EulerianSUPG( + self.mesh, + sympy.Matrix.zeros(self.mesh.dim, self.mesh.dim), + self.u.sym, + transport_on_update=True, + **common, + ) + else: + self.Unknowns.DFDt = uw.systems.ddt.SemiLagrangian( + self.mesh, + sympy.Matrix.zeros(self.mesh.dim, self.mesh.dim), + self.u.sym, + **ddt_kwargs, + **common, + ) + # Stress flux = 2·viscosity·E_eff references psi_star[0] in E_eff's + # history term — without snapshot substitution the projection of + # flux→psi_star[0] becomes implicit in psi_star[0] and Min-mode at + # yield admits the wrong fixed point under timestep change. + self.Unknowns.DFDt.enable_source_snapshot() + # the history stores the model's encoding of a stress; an inflow datum + # is given as a stress and stored through the same encoding + self.Unknowns.DFDt._encode = getattr(cm, "encode_history", None) + + class _ConstitutiveModelStateMixin: """Single definition of the constitutive-model readiness flag. @@ -1394,7 +1679,7 @@ def _penalty_value(penalty_expression): return float("nan") # symbolic: not a number, so not zero -class SNES_Stokes(_ConstitutiveModelStateMixin, SNES_Stokes_SaddlePt): +class SNES_Stokes(_ConstitutiveModelStateMixin, _StressHistoryMixin, SNES_Stokes_SaddlePt): r""" Stokes equation solver for incompressible viscous flow. @@ -1579,73 +1864,13 @@ def set_jacobian_F1_source(self, F1_source, linesearch="cp"): predicted step at the optimum of the local linearisation and converges cleanly on the same problems where ``bt`` flails. Set to ``None`` to leave the linesearch type untouched (e.g. - if you've already configured one via ``petsc_options``). - Has no effect when ``F1_source is None``. - """ - self._F1_jacobian_source = F1_source - self._needs_function_rewire = True - if F1_source is not None and linesearch is not None: - self.petsc_options["snes_linesearch_type"] = linesearch - - @property - def stress_transport(self) -> str: - """How a viscoelastic stress history is carried: ``"backward_nodes"`` - (default), ``"backward_integration_points"``, - ``"forward_integration_points"``, ``"forward_nodes"``, ``"lagrangian"`` - or ``"eulerian"``. - - The first four are the semi-Lagrangian schemes of - :func:`~underworld3.systems.ddt.SemiLagrangian`, named by the direction - of the trace and the points the history is held at. A backward trace - follows the characteristic back from each storage point and samples the - old stress at the departure point. ``"backward_nodes"`` stores the - history on a nodal field, which the assembler then interpolates to the - integration points: two interpolations a step. - ``"backward_integration_points"`` traces back to the integration points - themselves and holds the history there: one evaluation error and no - projection. A forward trace launches the old stress from where it is - known, carries it one step forward and fits the arrivals in each cell. - ``"forward_integration_points"`` launches from the integration points, - where the stress is formed, and reads the constitutive flux there - through a continuous P1 projection; it holds the Maxwell start-up below - Courant one where the backward integration-point history rings (see - :class:`~underworld3.systems.ddt.ForwardIntegrationPointsSemiLagrangian`). - ``"forward_nodes"`` launches the stress projected onto the continuous - history space from its nodes and from a lattice inside each element (see - :class:`~underworld3.systems.ddt.ForwardNodesSemiLagrangian`). - - ``"eulerian"`` transports the stress on the grid with the same - streamline-upwind stabilisation the Eulerian solvers use, and gives the - same answer on any partition. ``"lagrangian"`` carries the stress on a - swarm of material points the solver creates and advects, reading the - constitutive flux at the particles each step and never projecting it - back to the mesh: no numerical diffusion of the history, at the cost of - the swarm (see :class:`~underworld3.systems.ddt.Lagrangian`). - - Set it before the constitutive model is assigned: assigning the model - creates the history, and the choice cannot change after that. The - former names ``"semi_lagrangian"``, ``"integration_point"`` and - ``"forward"`` are accepted, with a warning. + if you've already configured one via ``petsc_options``). + Has no effect when ``F1_source is None``. """ - return getattr(self, "_stress_transport", "backward_nodes") - - @stress_transport.setter - def stress_transport(self, value): - value = str(value) - if value in _RENAMED_TRANSPORTS: - warnings.warn( - f"stress_transport={value!r} is now {_RENAMED_TRANSPORTS[value]!r}", - FutureWarning, stacklevel=2) - value = _RENAMED_TRANSPORTS[value] - if value not in (*_SEMI_LAGRANGIAN_TRANSPORTS, "lagrangian", "eulerian"): - raise ValueError( - f"stress_transport must be one of {tuple(_SEMI_LAGRANGIAN_TRANSPORTS)} or " - f"'lagrangian' or 'eulerian', not {value!r}.") - if self.Unknowns.DFDt is not None: - raise RuntimeError( - "the stress history already exists: set stress_transport before the " - "constitutive model that asks for one.") - self._stress_transport = value + self._F1_jacobian_source = F1_source + self._needs_function_rewire = True + if F1_source is not None and linesearch is not None: + self.petsc_options["snes_linesearch_type"] = linesearch # ----- DEVSS: stabilising a discontinuous elastic stress ----- @@ -1735,214 +1960,6 @@ def _devss_refresh(self, verbose=False): if i != j: self._devss_D.array[:, j, i] = values - def _stress_history_prepare(self, timestep, _force_setup=False): - """Set the elastic timestep and the flags a rebuild depends on. - - Runs BEFORE the solver is built. The effective order of the stress - history ramps over the opening steps, and when it changes the compiled - functions must be rewired; setting that flag after the build has already - decided whether to set up leaves the managed multigrid block asking a - preconditioner for sub-solvers it has not created (#727). - """ - # dt_elastic must always equal the solve timestep. The constitutive - # model's VE formulas (eta_eff, stress history terms) all reference - # Parameters.dt_elastic. If it differs from the actual timestep, - # the stress computation is inconsistent with the time integration. - self.constitutive_model.Parameters.dt_elastic = timestep - # The integrator coefficients must be current BEFORE the history is - # first carried: a trace-back history initialises its first level from - # the constitutive flux of the velocity it finds, and with the - # exponential integrator that flux read the viscous-limit coefficients - # (alpha = phi = 0) until the update that used to follow the carry - # (#740). The BDF coefficients are refreshed again after the carry, as - # before, since they read the step history the carry updates. - self.constitutive_model._update_history_coefficients() - - if _force_setup: - self._needs_function_rewire = True - - # Re-setup when effective_order changes (DDt history ramp-up) - _current_eff_order = self.constitutive_model.effective_order - if _current_eff_order != self._prev_effective_order: - self._needs_function_rewire = True - self.constitutive_model._solver_is_setup = False - self._prev_effective_order = _current_eff_order - - if not self.constitutive_model._solver_is_setup: - self._needs_function_rewire = True - self.DFDt.psi_fn = _history_psi_fn(self.constitutive_model) - # D starts from the velocity as it is now, so the DEVSS pair - # cancels on the first step as it does on every later one. - self._devss_refresh() - - def _stress_history_advance(self, timestep, verbose=False, evalf=False): - """Carry the stress history to where the momentum solve will read it. - - Runs AFTER the solver is built, and once per step: a solver that takes - several passes over the momentum equation (the Navier-Stokes one, with - its Picard corrections) must not advance the history once per pass. - """ - if uw.mpi.rank == 0 and verbose: - print("Stokes solver - carry the stress history", flush=True) - - self.DFDt.update_pre_solve(timestep, verbose=verbose, evalf=evalf, - store_result=False) - # Uniform pre-solve coefficient hook: VEP delegates to - # _update_bdf_coefficients(); MaxwellExponentialFlowModel updates - # α, φ on the DDt via _update_exp_coefficients(). No isinstance - # checks at the solver layer. - self.constitutive_model._update_history_coefficients() - - def _stress_history_post_solve(self, timestep, verbose=False, evalf=False): - """Commit the stress the solve produced and shift the history levels.""" - # The history manager places the new stress in level 0 and shifts the - # levels. A particle-carried history does that itself in its post-solve, - # by evaluating the new stress at its own particles. - if not self.DFDt.commits_flux_in_post_solve: - self.DFDt.commit_flux_to_history(_history_record(self.constitutive_model), verbose=verbose) - - self.DFDt.update_post_solve(timestep, verbose=verbose, evalf=evalf) - self._devss_refresh(verbose=verbose) - - # Uniform post-solve hook for any extra integrator-state storage. - # VEP: no-op. ETD-2 / MaxwellExponentialFlowModel: refresh - # forcing_star with current ε̇^{n+1} so the next step's history - # term has access to ε̇ⁿ. - self.constitutive_model._update_history_post_solve() - - self.is_setup = True - self.constitutive_model._solver_is_setup = True - - def _create_stress_history_ddt(self, order=2): - """Create DFDt for stress history tracking (VE/VEP models). - - Called automatically when a constitutive model with - ``requires_stress_history = True`` is assigned. Can also be called - explicitly to pre-create the DFDt with a specific order. - :attr:`stress_transport` chooses which flavour carries it. - - Constitutive models can inject extra SemiLagrangian kwargs via the - ``stress_history_ddt_kwargs`` property — used e.g. by - ``MaxwellExponentialFlowModel`` to set ``with_forcing_history=True``. - """ - if self.Unknowns.DFDt is not None: - return # already created - - self._order = order - # Constitutive model may request extra SemiLagrangian kwargs (e.g. - # with_forcing_history for ETD-2 integration). - cm = getattr(self, "constitutive_model", None) - ddt_kwargs = {} - if cm is not None: - ddt_kwargs = dict(getattr(cm, "stress_history_ddt_kwargs", {})) - - common = dict( - vtype=uw.VarType.SYM_TENSOR, - degree=self.u.degree - 1, - continuous=True, - varsymbol=rf"{{F[ {self.u.symbol} ] }}", - verbose=self.verbose, - bcs=None, - order=order, - smoothing=0.0001, - # the history carries a stress (the flux handed over at construction - # is a zero placeholder, so it cannot say so itself), or the - # dimensionless log-conformation of one - units=uw.units.Pa if getattr(cm, "_stress_history", "stress") == "stress" else None, - ) - if self.stress_transport == "backward_integration_points": - unsupported = set(ddt_kwargs) - {"with_forcing_history"} - if unsupported: - raise NotImplementedError( - f"{type(cm).__name__} asks its stress history for " - f"{sorted(unsupported)}, which the integration-point flavour " - "does not provide; use stress_transport='backward_nodes'.") - self.Unknowns.DFDt = uw.systems.ddt.BackwardIntegrationPointsSemiLagrangian( - self.mesh, - sympy.Matrix.zeros(self.mesh.dim, self.mesh.dim), - self.u.sym, - **ddt_kwargs, - **{k: v for k, v in common.items() if k != "smoothing"}, - ) - elif _SEMI_LAGRANGIAN_TRANSPORTS.get(self.stress_transport, ("",))[0] == "forward": - if ddt_kwargs: - raise NotImplementedError( - f"{type(cm).__name__} asks its stress history for " - f"{sorted(ddt_kwargs)}, which the forward flavours do not provide; " - "use stress_transport='backward_nodes' for it.") - self.Unknowns.DFDt = uw.systems.ddt.SemiLagrangian( - self.mesh, - sympy.Matrix.zeros(self.mesh.dim, self.mesh.dim), - self.u.sym, - common["vtype"], - trace="forward", launch=_SEMI_LAGRANGIAN_TRANSPORTS[self.stress_transport][1], - degree=common["degree"], varsymbol=common["varsymbol"], - order=order, units=common["units"], - ) - elif self.stress_transport == "lagrangian": - if ddt_kwargs: - raise NotImplementedError( - f"{type(cm).__name__} asks its stress history for " - f"{sorted(ddt_kwargs)}, which the particle Lagrangian flavour does " - "not provide; use stress_transport='backward_nodes' for it.") - # Order 1 BDF only for now: the particle flavour has no exponential - # coefficients (it is built with_exp=False), and order 2 is not yet - # validated. Refuse cleanly rather than crash inside the first solve. - if getattr(cm, "_integrator", "bdf") != "bdf": - raise NotImplementedError( - "the particle Lagrangian stress history supports the BDF " - "integrator only; use stress_transport='backward_nodes' for the " - "exponential one.") - if order > 1: - raise NotImplementedError( - "the particle Lagrangian stress history is first order for now; " - "use stress_transport='backward_nodes' for order 2.") - # The solver owns the swarm: Lagrangian creates and populates it, and - # carries the stress on it. Lagrangian_Swarm (a user-supplied swarm) - # stays available by passing DFDt= to the constructor. - # A cells proxy (discontinuous, reconstructed from the particles in - # each cell) is what the particle history is validated on - # (test_0070); the continuous nodal store the mesh flavours use is - # not the right target for a swarm-carried field. - lag_common = {k: v for k, v in common.items() if k != "continuous"} - self.Unknowns.DFDt = uw.systems.ddt.Lagrangian( - self.mesh, - sympy.Matrix.zeros(self.mesh.dim, self.mesh.dim), - self.u.sym, - continuous=False, - proxy_location="cells", - **lag_common, - ) - elif self.stress_transport == "eulerian": - if ddt_kwargs: - raise NotImplementedError( - f"{type(cm).__name__} asks its stress history for " - f"{sorted(ddt_kwargs)}, which only the semi-Lagrangian flavour " - "provides; use stress_transport='backward_nodes' for it.") - self.Unknowns.DFDt = uw.systems.ddt.EulerianSUPG( - self.mesh, - sympy.Matrix.zeros(self.mesh.dim, self.mesh.dim), - self.u.sym, - transport_on_update=True, - **common, - ) - else: - self.Unknowns.DFDt = uw.systems.ddt.SemiLagrangian( - self.mesh, - sympy.Matrix.zeros(self.mesh.dim, self.mesh.dim), - self.u.sym, - **ddt_kwargs, - **common, - ) - # Stress flux = 2·viscosity·E_eff references psi_star[0] in E_eff's - # history term — without snapshot substitution the projection of - # flux→psi_star[0] becomes implicit in psi_star[0] and Min-mode at - # yield admits the wrong fixed point under timestep change. - self.Unknowns.DFDt.enable_source_snapshot() - # the history stores the model's encoding of a stress; an inflow datum - # is given as a stress and stored through the same encoding - self.Unknowns.DFDt._encode = getattr(cm, "encode_history", None) - @timing.routine_timer_decorator @memprobe.instrument("Stokes.solve") def solve( @@ -5401,7 +5418,7 @@ def solve( # This one is already updated to work with the Lagrange D_Dt -class SNES_NavierStokes(SNES_Stokes_SaddlePt): +class SNES_NavierStokes(_StressHistoryMixin, SNES_Stokes_SaddlePt): r""" Navier-Stokes equation solver with momentum advection. @@ -5503,6 +5520,8 @@ def __init__( velocity_transport: str = "backward_nodes", ): ## Parent class will set up default values and load u_Field into the solver + # TODO(BUG): the time order lands in the base class's `degree` slot + # (positional); the base does not use it once the fields are given. super().__init__( mesh, velocityField, @@ -5521,7 +5540,10 @@ def __init__( self._rho = expression(R"{\uprho}", rho, "Density") self._first_solve = True - self._order = order + # The momentum time order. (The base class's _order is the + # viscoelastic order: the stress history's, which the constitutive + # model sets.) + self._momentum_order = order self._flux_order = flux_order # None means follow effective_order self._penalty = expression(R"{\uplambda}", 0, "Incompressibility Penalty") @@ -5543,19 +5565,20 @@ def __init__( if self.Unknowns.DuDt is None: self.Unknowns.DuDt = _value_history( velocity_transport, self.mesh, self.u, self.u.sym, uw.VarType.VECTOR, - order=self._order, + order=self._momentum_order, nodal_options=dict(verbose=self.verbose, bcs=self.essential_bcs, smoothing=0.0001), requested={}, ) - # F (at least for N-S) is a nodal point variable so there is no benefit - # to treating it as a swarm variable. We'll define and use our own SL tracker - # as we do in the SLCN version. We'll leave the option for an over-ride. - # - # Maybe u.degree-1. The scalar equivalent seems to show - # little benefit from specific choices here other than - # discontinuous flux variables amplifying instabilities. + # The flux history is built with the constitutive model: a viscoelastic + # model gets the stress history :attr:`stress_transport` names (as the + # Stokes family does), any other the viscous-flux history of the theta + # rule (see _create_flux_history). A DFDt passed in is used as given. + return + def _create_flux_history(self): + """The theta rule's history of the viscous flux, for a model without a + stress history of its own: carried back from the velocity nodes.""" self.Unknowns.DFDt = uw.systems.ddt.SemiLagrangian( self.mesh, sympy.Matrix.zeros(self.mesh.dim, self.mesh.dim), @@ -5566,12 +5589,9 @@ def __init__( varsymbol=rf"{{ F[ {self.u.symbol} ] }}", verbose=self.verbose, bcs=None, - order=self._order, + order=self._momentum_order, ) - - ## Add in the history terms provided ... - - return + self.Unknowns.DFDt.psi_fn = self.constitutive_model.flux.T @property def F0(self): @@ -5594,14 +5614,33 @@ def F1(self): dim = self.mesh.dim DFDt = self.Unknowns.DFDt - - if DFDt is not None: - # We can flag to only do this if the constitutive model has been updated - if getattr(self._constitutive_model, "_stress_history", "stress") != "stress": - raise NotImplementedError( - "SNES_NavierStokes reads its history as a flux (Adams-Moulton), so it cannot " - "decode a log-conformation history; use uw.systems.NavierStokes") - DFDt.psi_fn = getattr(self._constitutive_model, 'history_flux', self._constitutive_model.flux).T + cm = self._constitutive_model + + if DFDt is not None and getattr(cm, "requires_stress_history", False): + # The theta rule on the momentum flux: the new stress (the model's + # flux, solvent included) and, at each stored level, the stress the + # history carries -- decoded, so a log-conformation store reads as a + # stress -- plus the solvent stress of the velocity carried there. + coefficients = DFDt._am_coeffs + eta_s = getattr(cm.Parameters, "solvent_viscosity", 0) + u_levels = self.Unknowns.DuDt.psi_star + flux = coefficients[0] * sympy.Matrix(cm.flux).T + for level, weight in enumerate(coefficients[1:]): + carried = sympy.Matrix(cm._carried_stress_sym(level) + if hasattr(cm, "_carried_stress_sym") + else DFDt.psi_star[level].sym) + u_carried = u_levels[min(level, len(u_levels) - 1)].sym + solvent = 2 * eta_s * sympy.Matrix(self.mesh.vector.strain_tensor(u_carried)) + flux = flux + weight * (carried + solvent) + F1 = expression( + r"\mathbf{F}_1\left( \mathbf{u} \right)", + flux + - sympy.eye(self.mesh.dim) * (self.p.sym[0]) + + self.penalty * self.div_u * sympy.eye(dim), + "NStokes pointwise flux term: F_1(u)", + ) + elif DFDt is not None: + DFDt.psi_fn = cm.flux.T F1 = expression( r"\mathbf{F}_1\left( \mathbf{u} \right)", @@ -5792,8 +5831,8 @@ def solve( many times. 0 preserves legacy behaviour. """ - if order is None or order > self._order: - order = self._order + if order is None or order > self._momentum_order: + order = self._momentum_order if timestep is not None and timestep != self.delta_t: self.delta_t = timestep # this will force an initialisation because the functions need to be updated @@ -5801,20 +5840,17 @@ def solve( if _force_setup: self._needs_function_rewire = True - if not self.constitutive_model._solver_is_setup: - self._needs_function_rewire = True - self.DFDt.psi_fn = _history_psi_fn(self.constitutive_model) - - # A viscoelastic constitutive model integrates its stress over the - # solve step: it has to be told the step, and its integrator - # coefficients refreshed, exactly as the Stokes family does in - # _stress_history_prepare / _stress_history_advance. Without this the - # memory term was silently absent here (dt_elastic never set) and the - # exponential integrator ran in its viscous limit (#741). _cm = self.constitutive_model - if getattr(_cm, "requires_stress_history", False) and hasattr(_cm.Parameters, "dt_elastic"): - _cm.Parameters.dt_elastic = timestep - _cm._update_history_coefficients() + viscoelastic = getattr(_cm, "requires_stress_history", False) + if viscoelastic: + # the stress history's life in a step is the Stokes family's + self._stress_history_prepare(timestep, _force_setup=_force_setup) + else: + if self.Unknowns.DFDt is None: + self._create_flux_history() + if not _cm._solver_is_setup: + self._needs_function_rewire = True + self.DFDt.psi_fn = _history_psi_fn(_cm) if not self.is_setup: self._setup_pointwise_functions(verbose) @@ -5833,11 +5869,12 @@ def solve( if trace is not None: trace.begin_step(timestep) self.DuDt.update_pre_solve(timestep, verbose=verbose, evalf=_evalf) - self.DFDt.update_pre_solve(timestep, verbose=verbose, evalf=_evalf) + if viscoelastic: + self._stress_history_advance(timestep, verbose=verbose, evalf=_evalf) + else: + self.DFDt.update_pre_solve(timestep, verbose=verbose, evalf=_evalf) if trace is not None: trace.finish_step() - if getattr(_cm, "requires_stress_history", False): - _cm._update_history_coefficients() # BDF reads the carried step history # Override AM coefficients if flux_order is explicitly set if self._flux_order is not None: @@ -5860,7 +5897,10 @@ def solve( print(f"NS solver - post-solve DuDt update", flush=True) self.DuDt.update_post_solve(timestep, verbose=verbose, evalf=_evalf) - self.DFDt.update_post_solve(timestep, verbose=verbose, evalf=_evalf) + if viscoelastic: + self._stress_history_post_solve(timestep, verbose=verbose, evalf=_evalf) + else: + self.DFDt.update_post_solve(timestep, verbose=verbose, evalf=_evalf) self.is_setup = True self.constitutive_model._solver_is_setup = True diff --git a/tests/test_1059_stress_transport.py b/tests/test_1059_stress_transport.py index 12625e132..c34aadb43 100644 --- a/tests/test_1059_stress_transport.py +++ b/tests/test_1059_stress_transport.py @@ -158,7 +158,7 @@ def test_transport_is_off_unless_asked_for(): def _maxwell_shear(transport, order, steps=20, dt=0.1, integrator="bdf", solver="stokes", initial_velocity=False, - objective_rate="none", solvent=0.0): + objective_rate="none", solvent=0.0, stress_history="stress"): """The analytic Maxwell shear box, with the stress history of one's choosing. Simple shear of a Maxwell material: sigma_xy = eta gammadot (1 - exp(-t/t_r)). @@ -182,7 +182,8 @@ def _maxwell_shear(transport, order, steps=20, dt=0.1, integrator="bdf", solver= stokes.bodyforce = sympy.Matrix([[0.0, 0.0]]) stokes.stress_transport = transport stokes.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( - stokes.Unknowns, order=order, integrator=integrator, objective_rate=objective_rate) + stokes.Unknowns, order=order, integrator=integrator, objective_rate=objective_rate, + stress_history=stress_history) stokes.constitutive_model.Parameters.shear_viscosity_0 = eta stokes.constitutive_model.Parameters.shear_modulus = shear_modulus stokes.constitutive_model.Parameters.solvent_viscosity = solvent @@ -202,11 +203,10 @@ def _maxwell_shear(transport, order, steps=20, dt=0.1, integrator="bdf", solver= for _ in range(steps): stokes.solve(timestep=dt, zero_init_guess=False) - # After a solve the Stokes family has committed the new stress into the - # history's first level; the trace-back Navier-Stokes solver records it at - # the NEXT carry, so there its first level still holds the previous step - # and the stress just solved for is the constitutive flux (#742). - latest = stokes.DFDt.psi_star[0].sym if solver == "stokes" else stokes.constitutive_model.flux + # After a solve every solver has committed the new stress into the + # history's first level (read through the model: a log-conformation store + # decodes to a stress). + latest = stokes.constitutive_model._carried_stress_sym(0) origin = np.array([[0.0, 0.0]]) stress = float(np.asarray(uw.function.evaluate(latest[0, 1], origin)).reshape(-1)[0]) rate = 2.0 * speed / height @@ -773,3 +773,26 @@ def test_the_store_smoothing_is_the_coefficient_times_the_local_cell_size_square assert abs(a - 0.05 * h * h) < 1.0e-12 * max(1.0, h * h) with pytest.raises(ValueError): history.store_smoothing = -1.0 + + +@pytest.mark.parametrize("transport", list(KINDS)) +def test_the_semi_lagrangian_navier_stokes_carries_every_stress_history(transport): + """NavierStokesSLCN takes stress_transport as the Stokes family does, and at + negligible inertia gives the analytic Maxwell shear stress with each.""" + kind, stress, exact = _maxwell_shear(transport, 1, solver="ns_slcn") + assert kind == KINDS[transport] + assert abs(stress - exact) / exact < 0.02, (transport, stress, exact) + + +def test_the_semi_lagrangian_navier_stokes_reads_a_log_conformation_history(): + """The log-conformation store through NavierStokesSLCN, on upper-convected + start-up (lambda = 1, gammadot = 1, t = 2): the shear stress is + eta gammadot (1 - e^-t) and the first normal-stress difference + 2 eta lambda gammadot^2 [1 - e^-t (1 + t)] = 1.188. The deformation step + the log store uses gives 1.193; the linear step of the stress store 1.108.""" + _, s_log, exact, n1_log, _ = _maxwell_shear( + "forward_integration_points", 1, solver="ns_slcn", objective_rate="upper_convected", + stress_history="log_conformation") + n1_exact = 2.0 * (1.0 - np.exp(-2.0) * 3.0) + assert abs(s_log - exact) / exact < 0.02, (s_log, exact) + assert abs(n1_log - n1_exact) / n1_exact < 0.01, (n1_log, n1_exact) diff --git a/tests/test_1104_navier_stokes_supg_memory_stress.py b/tests/test_1104_navier_stokes_supg_memory_stress.py new file mode 100644 index 000000000..b207f2f25 --- /dev/null +++ b/tests/test_1104_navier_stokes_supg_memory_stress.py @@ -0,0 +1,98 @@ +"""The SUPG residual of uw.systems.NavierStokes sees the carried elastic stress. + +SUPG weights the strong momentum residual along streamlines; a residual missing +a term that does not vanish at the exact solution injects an O(tau) error. The +divergence of the stress a viscoelastic history carries is such a term, and at +high Weissenberg number the largest one. On a developing Oldroyd-B channel +(Re 250, Wi 0.6, beta 0.59, stress-free fluid entering) it moved the SUPG +solution toward the Galerkin one on the same mesh: velocity 5.0e-5 -> 3.2e-5, +stress 3.4e-6 -> 1.6e-6 (~/+Simulations/supg_memory, 2026-09-28). +""" + +import numpy as np +import pytest +import sympy + +import underworld3 as uw + +pytestmark = [pytest.mark.level_2, pytest.mark.tier_b] + +L, H, U = 2.0, 0.5, 1.0 +ETA, BETA, WI = 1.0 / 250.0, 0.59, 0.6 +POINTS = np.array([[0.4, 0.3], [0.8, -0.2]]) +# BASELINES: u_x and sigma_xy at POINTS after ten steps (2026-09-28) +U_X = (0.639601419, 0.840013154) +SIGMA_XY = (-0.00331132215, 0.00220579959) + + +def developing_channel(transport="forward_integration_points", steps=10, dt=0.05, cell=0.1): + eta_s, eta_p = BETA * ETA, (1 - BETA) * ETA + lam = WI * H / U + mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, -H), maxCoords=(L, H), + cellSize=cell, qdegree=3, regular=False) + x, y = mesh.X + v = uw.discretisation.MeshVariable("U_ch", mesh, 2, degree=2) + p = uw.discretisation.MeshVariable("P_ch", mesh, 1, degree=1) + ns = uw.systems.NavierStokes(mesh, v, p, rho=1.0) + ns.stress_transport = transport + ns.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + ns.Unknowns, order=1, integrator="etd", objective_rate="upper_convected") + cm = ns.constitutive_model + cm.Parameters.shear_viscosity_0 = eta_p + cm.Parameters.shear_modulus = eta_p / lam + cm.Parameters.solvent_viscosity = eta_s + cm.Parameters.dt_elastic = dt + u_in = U * (1 - y ** 2 / H ** 2) + ns.add_dirichlet_bc((u_in, 0.0), "Left") + ns.add_dirichlet_bc((0.0, 0.0), "Top") + ns.add_dirichlet_bc((0.0, 0.0), "Bottom") + ns.bodyforce = sympy.Matrix([[0.0, 0.0]]) + ns.DFDt.inflow_value = sympy.Matrix([[0.0, 0.0], [0.0, 0.0]]) + ns.tolerance = 1.0e-8 + v.array[:, 0, :] = np.column_stack([ + np.asarray(uw.function.evaluate(u_in, v.coords)).reshape(-1), np.zeros(v.coords.shape[0])]) + for _ in range(steps): + ns.solve(timestep=dt) + return ns, v + + +def test_the_supg_residual_carries_the_memory_stress_and_no_velocity_derivative(): + uw.reset_default_model() + ns, v = developing_channel(steps=1) + memory = ns._memory_stress() + assert memory is not None + X = ns.mesh.X + own = {ns.u.sym[i].diff(X[j]) for i in range(2) for j in range(2)} + assert not (sympy.Matrix(memory).atoms(sympy.Function) & own) + assert any(e != 0 for e in sympy.Matrix(memory)) + + +def test_a_viscous_fluid_has_no_memory_stress(): + uw.reset_default_model() + mesh = uw.meshing.UnstructuredSimplexBox(cellSize=0.25) + v = uw.discretisation.MeshVariable("U_vf", mesh, 2, degree=2) + p = uw.discretisation.MeshVariable("P_vf", mesh, 1, degree=1) + ns = uw.systems.NavierStokes(mesh, v, p, rho=1.0) + ns.constitutive_model = uw.constitutive_models.ViscousFlowModel + assert ns._memory_stress() is None + + +def test_an_integration_point_store_is_read_through_its_nodal_snapshot(): + """An integration-point store has no derivative: the SUPG residual takes the + divergence of its nodal snapshot instead.""" + uw.reset_default_model() + ns, v = developing_channel("backward_integration_points", steps=1) + atoms = sympy.Matrix(ns._memory_stress()).atoms(sympy.Function) + ip = set(sympy.Matrix(ns.DFDt.psi_star[0].sym)) + snapshot = set(sympy.Matrix(ns.DFDt.psi_snap[0].sym)) + assert not (atoms & ip) and (atoms & snapshot) + + +def test_the_developing_channel_keeps_its_recorded_flow(): + uw.reset_default_model() + ns, v = developing_channel() + ux = np.asarray(uw.function.global_evaluate(v.sym[0], POINTS)).reshape(-1) + sxy = np.asarray(uw.function.global_evaluate( + ns.constitutive_model._carried_stress_sym(0)[0, 1], POINTS)).reshape(-1) + assert np.allclose(ux, U_X, atol=1.0e-7), ux + assert np.allclose(sxy, SIGMA_XY, atol=1.0e-9), sxy From c0704a85192028d2fee2b3fb2f0e0e6202f85866 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Tue, 29 Sep 2026 12:04:33 -1000 Subject: [PATCH 07/21] Review (2adf50d0): momentum weights for the theta rule, parameter gradients in the SUPG memory term, snapshot stand-ins - Both Navier-Stokes solvers form the viscoelastic momentum flux with one helper (_StressHistoryMixin._theta_rule_flux) using the MOMENTUM scheme's weights (DuDt.spatial_weights: BDF puts it all on the new level, the theta rule at first order); NavierStokesSLCN used the stress history's AM weights, a BDF2 derivative against a Crank-Nicolson flux by default. A stored level the stress history does not hold is an error, not a clamp. - _memory_stress is the model's own flux with the velocity's derivatives set to zero (any model: the transversely isotropic one too; the solvent drops out), with expression containers that vary in space expanded first so a varying modulus or viscosity contributes its gradient; constant containers stay runtime parameters. - History stores without a derivative give their stand-ins through one method (_derivative_stand_ins: the integration-point history's nodal snapshots, psi and forcing); used by the SUPG memory term and by the solvent term of an integration-point velocity history, which failed to compile with a viscoelastic model. - Constitutive_Model._carried_stress_sym default (the stored level); the hasattr branches go. - NavierStokesSLCN builds its viscous-flux history when a non-viscoelastic model is assigned (DFDt available before the first solve, as the tutorial expects); the flux_order override applies to that history only. - docs: stress-transport "With inertia" matches the code. - tests: test_1104 adds a numeric memory check, the varying-modulus expansion, NavierStokesSLCN on the developing channel (solvent, log store, momentum order 2 against model order 1) against recorded values, and an integration-point velocity history with a viscoelastic model. Serial set 407 passed; np 3/6 transport tests pass; tests/parallel np 4 as before (#801 pre-existing, #797 xfail). Underworld development team with AI support from Claude Code Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo --- docs/developer/subsystems/stress-transport.md | 19 +++-- src/underworld3/constitutive_models.py | 6 ++ src/underworld3/systems/ddt.py | 19 +++++ .../systems/navier_stokes_eulerian.py | 80 ++++++++----------- src/underworld3/systems/solvers.py | 71 +++++++++++----- ...t_1104_navier_stokes_supg_memory_stress.py | 67 ++++++++++++++-- 6 files changed, 178 insertions(+), 84 deletions(-) diff --git a/docs/developer/subsystems/stress-transport.md b/docs/developer/subsystems/stress-transport.md index afbade12b..c16b00ecd 100644 --- a/docs/developer/subsystems/stress-transport.md +++ b/docs/developer/subsystems/stress-transport.md @@ -106,15 +106,18 @@ the model's decode. - `uw.systems.NavierStokes` transports momentum on the grid with SUPG. The SUPG term weights the strong momentum residual, and that residual includes the divergence of the stress the history carries (at high Weissenberg number - the largest term of the balance). The integration-point store has no - derivative, so `backward_integration_points` is refused here; the viscous - term and the terms that carry the velocity gradient itself (the objective - rate's) need second derivatives and remain outside the residual. + the largest term of the balance), gradients of a spatially varying modulus + or viscosity included. An integration-point store has no derivative; its + nodal snapshot stands in, an O(dt) difference. The viscous term, the terms + that carry the velocity gradient itself (the objective rate's) and a + yielding viscosity need second derivatives and remain outside the residual. - `uw.systems.NavierStokesSLCN` carries the velocity semi-Lagrangianly - (`velocity_transport=`) and applies the theta rule to the momentum flux: the - new stress, and at each stored level the carried stress plus the solvent - stress of the carried velocity. Its time order is the momentum's; the stress - history's order is the constitutive model's. + (`velocity_transport=`). +- Both apply the momentum scheme's weights to the momentum flux (the theta + rule at first order, the new level alone for BDF): the new stress, and at a + stored level the carried stress plus the solvent stress of the velocity + there. The momentum's time order is the solver's; the stress history's is + the constitutive model's. The particle history differs from serial by about 1e-5 at np 4 and 6 (np 3 diff --git a/src/underworld3/constitutive_models.py b/src/underworld3/constitutive_models.py index 6b65eaefa..4379fcfa9 100644 --- a/src/underworld3/constitutive_models.py +++ b/src/underworld3/constitutive_models.py @@ -525,6 +525,12 @@ def c(self): else: return self._c.as_immutable() + def _carried_stress_sym(self, level=0): + r"""The stress the history carries at level ``level``: its stored value. + A model that stores something else (the log-conformation history) + decodes it here.""" + return self.Unknowns.DFDt.psi_star[level].sym + @property def flux(self): """Computes the effect of the constitutive tensor on the gradients of the unknowns. diff --git a/src/underworld3/systems/ddt.py b/src/underworld3/systems/ddt.py index 4eba59a2f..df0364a5f 100644 --- a/src/underworld3/systems/ddt.py +++ b/src/underworld3/systems/ddt.py @@ -1180,6 +1180,11 @@ def _nondim_timestep(self, dt): reduced = _as_float(dt) return dt if reduced is None else reduced + def _derivative_stand_ins(self): + """What to read in place of each stored symbol where a derivative of it + is needed: nothing, for a store that has one.""" + return {} + def _project_nodally(self, expr, name="flux", smoothing=0.0, verbose=False): """L2 projection of the stored components of ``expr`` onto a field of the history's degree and continuity; returns that (1, ncomponents) field. @@ -4814,6 +4819,20 @@ class BackwardIntegrationPointsSemiLagrangian(_DDtBase): #: there when one is set (#745); without one they sample the edge. applies_inflow_value = True + def _derivative_stand_ins(self): + """The integration-point stores have no derivative. Their values are the + nodal snapshots sampled at the departure points, so where a derivative + is needed the snapshot stands in: the field where it was rather than + where it has been carried, an O(dt) difference (more at an inflow, + where the points take the inflow value and the snapshot does not).""" + pairs = list(zip(self.psi_star, self.psi_snap)) + if self.forcing_star is not None: + pairs.append((self.forcing_star, self.forcing_snap)) + stand_in = {} + for store, snapshot in pairs: + stand_in.update(zip(sympy.Matrix(store.sym), sympy.Matrix(snapshot.sym))) + return stand_in + def commit_flux_to_history(self, flux, verbose=False): """Project the new flux into the nodal snapshot and read it at the points, then shift both ladders. diff --git a/src/underworld3/systems/navier_stokes_eulerian.py b/src/underworld3/systems/navier_stokes_eulerian.py index 391a2a3e1..c83641f8b 100644 --- a/src/underworld3/systems/navier_stokes_eulerian.py +++ b/src/underworld3/systems/navier_stokes_eulerian.py @@ -40,6 +40,18 @@ from underworld3.systems.advection_diffusion_eulerian import _check_supplied_manager from underworld3.systems.solvers import SNES_Stokes, _dimensionalise_dt +def _expand_spatial(expr): + """``expr`` with every expression container that varies in space replaced + by its content, repeatedly; containers holding constants stay, so they + remain runtime parameters of the compiled form.""" + from underworld3.function.expressions import UWexpression + while True: + spatial = {a: a.sym for a in expr.atoms(UWexpression) if not a.is_uw_constant()} + if not spatial: + return expr + expr = expr.xreplace(spatial) + + _ADVECTION_MODES = ("extrapolated", "implicit") @@ -408,17 +420,20 @@ def _strong_residual(self, with_pressure=False): def _memory_stress(self): r"""The part of the momentum flux the stress history carries, or ``None``. - The model's flux is :math:`2\eta\,\dot\varepsilon_\mathrm{eff}`, and the - effective strain rate is the velocity's own strain rate plus the terms - of the history (the carried stress, decoded from a log-conformation - store, and a stored strain rate for the exponential integrator). With - the velocity's derivatives set to zero, what remains is the history's - part, whichever integrator made it; the stored levels of the theta rule - are history too. Its divergence needs only first derivatives of the - stores (of an integration-point store's nodal snapshot). Terms that carry the velocity gradient itself (the objective - rate's, the deformation step's) go with the viscous term: their - divergence needs second derivatives of the velocity, as does that of a - yielding material's strain-rate-dependent viscosity. + The model's flux with the velocity's own derivatives set to zero: what + remains is the history's part (the carried stress, decoded from a + log-conformation store, and a stored strain rate for the exponential + integrator), for any model and integrator, and the solvent stress drops + out. The stored levels of the theta rule are history too. Expression + containers that vary in space are expanded first, so the divergence + sees the gradient of a varying modulus or viscosity; constant ones (the + timestep, the integrator weights) stay runtime parameters. The + divergence needs only first derivatives of the stores (of an + integration-point store's nodal snapshot). Terms that carry the + velocity gradient itself (the objective rate's, the deformation + step's) go with the viscous term: their divergence needs second + derivatives of the velocity, as does that of a yielding material's + strain-rate-dependent viscosity. """ history = self.Unknowns.DFDt cm = self.constitutive_model @@ -429,27 +444,13 @@ def _memory_stress(self): X = self.mesh.X dim = self.mesh.dim own_rate = {self.u.sym[i].diff(X[j]): 0 for i in range(dim) for j in range(dim)} - memory = 2 * cm.viscosity * sympy.Matrix(cm.E_eff.sym).xreplace(own_rate) weights = self.DuDt.spatial_weights() - memory = weights[0] * memory + memory = weights[0] * _expand_spatial(sympy.Matrix(cm.flux)).xreplace(own_rate) for level, w in enumerate(weights[1:]): if w == 0: continue - memory = memory + w * sympy.Matrix( - cm._carried_stress_sym(level) if hasattr(cm, "_carried_stress_sym") - else history.psi_star[level].sym) - # An integration-point store has no derivative. Its values are the nodal - # snapshot of the committed stress sampled at the departure points, so - # the snapshot stands in for it here: the divergence of the stress where - # it was rather than where it has been carried, an O(dt) difference. - stand_in = {} - for store, snapshot in ((getattr(history, "psi_star", []), getattr(history, "psi_snap", None)), - ([history.forcing_star] if getattr(history, "forcing_star", None) is not None - else [], [getattr(history, "forcing_snap", None)])): - for level, star in enumerate(store): - if getattr(star, "is_integration_point", False): - stand_in.update(zip(sympy.Matrix(star.sym), sympy.Matrix(snapshot[level].sym))) - return memory.xreplace(stand_in) if stand_in else memory + memory = memory + w * _expand_spatial(sympy.Matrix(cm._carried_stress_sym(level))) + return memory.xreplace(history._derivative_stand_ins()) def _viscous_stress(self, u_row): r"""Deviatoric stress ``2 eta strain(u)`` for a velocity row, with the @@ -472,27 +473,12 @@ def _viscous_flux(self): """ states = self.DuDt.states() weights = self.DuDt.spatial_weights() + if self.Unknowns.DFDt is not None: + return self._theta_rule_flux(weights, states[1:]) total = weights[0] * self.stress_deviator - stress_history = self.Unknowns.DFDt - for level, (w, u_k) in enumerate(zip(weights[1:], states[1:])): - if w == 0: - continue - if stress_history is None: + for w, u_k in zip(weights[1:], states[1:]): + if w != 0: total = total + w * self._viscous_stress(u_k) - elif level < len(stress_history.psi_star): - # the history carries the memory part only; a solvent viscosity - # is rebuilt from the stored velocity, as the new level has it - eta_s = getattr(self.constitutive_model.Parameters, "solvent_viscosity", 0) - solvent = 2 * eta_s * sympy.Matrix(self.mesh.vector.strain_tensor(u_k)) - carried = self.constitutive_model._carried_stress_sym(level) \ - if hasattr(self.constitutive_model, "_carried_stress_sym") \ - else stress_history.psi_star[level].sym - total = total + w * (sympy.Matrix(carried) + solvent) - else: - raise ValueError( - f"the time scheme weights the flux at level {level + 1}, but the " - f"stress history holds {len(stress_history.psi_star)} level(s): " - "give the constitutive model a higher order, or the solver a lower one.") return total def _stabilisation_flux(self): diff --git a/src/underworld3/systems/solvers.py b/src/underworld3/systems/solvers.py index d2806288b..650812ce2 100644 --- a/src/underworld3/systems/solvers.py +++ b/src/underworld3/systems/solvers.py @@ -521,6 +521,33 @@ class _StressHistoryMixin: def _devss_refresh(self, verbose=False): """Refresh a history stabilisation; a solver without one has nothing to do.""" + def _theta_rule_flux(self, weights, velocities): + r"""The momentum flux of the time scheme with a stress history. + + ``weights`` are the momentum scheme's weights of the spatial operator at + each level (1 on the new level for BDF, the Adams-Moulton weights for + the theta rule) and ``velocities`` the velocity at each stored level. + The new level takes the model's flux (the solvent stress included); a + stored level takes the stress the history carries there -- decoded, so + a log-conformation store reads as a stress -- plus the solvent stress of + the velocity at that level. + """ + cm = self.constitutive_model + history = self.Unknowns.DFDt + eta_s = getattr(cm.Parameters, "solvent_viscosity", 0) + total = weights[0] * sympy.Matrix(cm.flux) + for level, (w, u_k) in enumerate(zip(weights[1:], velocities)): + if w == 0: + continue + if level >= len(history.psi_star): + raise ValueError( + f"the time scheme weights the flux at level {level + 1}, but the " + f"stress history holds {len(history.psi_star)} level(s): give the " + "constitutive model a higher order, or the solver a lower one.") + solvent = 2 * eta_s * sympy.Matrix(self.mesh.vector.strain_tensor(u_k)) + total = total + w * (sympy.Matrix(cm._carried_stress_sym(level)) + solvent) + return total + @property def stress_transport(self) -> str: """How a viscoelastic stress history is carried: ``"backward_nodes"`` @@ -629,7 +656,7 @@ def _stress_history_advance(self, timestep, verbose=False, evalf=False): its Picard corrections) must not advance the history once per pass. """ if uw.mpi.rank == 0 and verbose: - print("Stokes solver - carry the stress history", flush=True) + print("carry the stress history", flush=True) self.DFDt.update_pre_solve(timestep, verbose=verbose, evalf=evalf, store_result=False) @@ -5576,6 +5603,19 @@ def __init__( # rule (see _create_flux_history). A DFDt passed in is used as given. return + @property + def constitutive_model(self): + """The constitutive model (see the base class).""" + return self._constitutive_model + + @constitutive_model.setter + def constitutive_model(self, model_or_class): + # the base builds a stress history for a viscoelastic model; any other + # gets the viscous-flux history of the theta rule + SNES_Stokes_SaddlePt.constitutive_model.fset(self, model_or_class) + if self.Unknowns.DFDt is None: + self._create_flux_history() + def _create_flux_history(self): """The theta rule's history of the viscous flux, for a model without a stress history of its own: carried back from the velocity nodes.""" @@ -5617,24 +5657,14 @@ def F1(self): cm = self._constitutive_model if DFDt is not None and getattr(cm, "requires_stress_history", False): - # The theta rule on the momentum flux: the new stress (the model's - # flux, solvent included) and, at each stored level, the stress the - # history carries -- decoded, so a log-conformation store reads as a - # stress -- plus the solvent stress of the velocity carried there. - coefficients = DFDt._am_coeffs - eta_s = getattr(cm.Parameters, "solvent_viscosity", 0) - u_levels = self.Unknowns.DuDt.psi_star - flux = coefficients[0] * sympy.Matrix(cm.flux).T - for level, weight in enumerate(coefficients[1:]): - carried = sympy.Matrix(cm._carried_stress_sym(level) - if hasattr(cm, "_carried_stress_sym") - else DFDt.psi_star[level].sym) - u_carried = u_levels[min(level, len(u_levels) - 1)].sym - solvent = 2 * eta_s * sympy.Matrix(self.mesh.vector.strain_tensor(u_carried)) - flux = flux + weight * (carried + solvent) + # the momentum scheme's weights; a velocity store without a + # derivative is read through its nodal snapshot for the solvent term + DuDt = self.Unknowns.DuDt + stand_in = DuDt._derivative_stand_ins() + velocities = [sympy.Matrix(u_k).xreplace(stand_in) for u_k in DuDt.states()[1:]] F1 = expression( r"\mathbf{F}_1\left( \mathbf{u} \right)", - flux + self._theta_rule_flux(DuDt.spatial_weights(), velocities) - sympy.eye(self.mesh.dim) * (self.p.sym[0]) + self.penalty * self.div_u * sympy.eye(dim), "NStokes pointwise flux term: F_1(u)", @@ -5846,8 +5876,6 @@ def solve( # the stress history's life in a step is the Stokes family's self._stress_history_prepare(timestep, _force_setup=_force_setup) else: - if self.Unknowns.DFDt is None: - self._create_flux_history() if not _cm._solver_is_setup: self._needs_function_rewire = True self.DFDt.psi_fn = _history_psi_fn(_cm) @@ -5876,8 +5904,9 @@ def solve( if trace is not None: trace.finish_step() - # Override AM coefficients if flux_order is explicitly set - if self._flux_order is not None: + # Override AM coefficients if flux_order is explicitly set (the + # viscous-flux history's; a stress history follows the momentum scheme) + if self._flux_order is not None and not viscoelastic: from underworld3.systems.ddt import _update_am_values fo = min(self._flux_order, self.DFDt.effective_order) _update_am_values(self.DFDt._am_coeffs, fo, 0.5) diff --git a/tests/test_1104_navier_stokes_supg_memory_stress.py b/tests/test_1104_navier_stokes_supg_memory_stress.py index b207f2f25..b6d175c45 100644 --- a/tests/test_1104_navier_stokes_supg_memory_stress.py +++ b/tests/test_1104_navier_stokes_supg_memory_stress.py @@ -25,7 +25,9 @@ SIGMA_XY = (-0.00331132215, 0.00220579959) -def developing_channel(transport="forward_integration_points", steps=10, dt=0.05, cell=0.1): +def developing_channel(transport="forward_integration_points", steps=10, dt=0.05, cell=0.1, + solver="supg", stress_history="stress", velocity_transport="backward_nodes", + modulus_varies=False): eta_s, eta_p = BETA * ETA, (1 - BETA) * ETA lam = WI * H / U mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, -H), maxCoords=(L, H), @@ -33,13 +35,18 @@ def developing_channel(transport="forward_integration_points", steps=10, dt=0.05 x, y = mesh.X v = uw.discretisation.MeshVariable("U_ch", mesh, 2, degree=2) p = uw.discretisation.MeshVariable("P_ch", mesh, 1, degree=1) - ns = uw.systems.NavierStokes(mesh, v, p, rho=1.0) + if solver == "supg": + ns = uw.systems.NavierStokes(mesh, v, p, rho=1.0) + else: + ns = uw.systems.NavierStokesSLCN(mesh, v, p, rho=1.0, order=2, + velocity_transport=velocity_transport) ns.stress_transport = transport ns.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( - ns.Unknowns, order=1, integrator="etd", objective_rate="upper_convected") + ns.Unknowns, order=1, integrator="etd", objective_rate="upper_convected", + stress_history=stress_history) cm = ns.constitutive_model cm.Parameters.shear_viscosity_0 = eta_p - cm.Parameters.shear_modulus = eta_p / lam + cm.Parameters.shear_modulus = eta_p / lam * ((1 + 0.5 * x) if modulus_varies else 1) cm.Parameters.solvent_viscosity = eta_s cm.Parameters.dt_elastic = dt u_in = U * (1 - y ** 2 / H ** 2) @@ -59,12 +66,27 @@ def developing_channel(transport="forward_integration_points", steps=10, dt=0.05 def test_the_supg_residual_carries_the_memory_stress_and_no_velocity_derivative(): uw.reset_default_model() ns, v = developing_channel(steps=1) - memory = ns._memory_stress() - assert memory is not None + memory = sympy.Matrix(ns._memory_stress()) X = ns.mesh.X own = {ns.u.sym[i].diff(X[j]) for i in range(2) for j in range(2)} - assert not (sympy.Matrix(memory).atoms(sympy.Function) & own) - assert any(e != 0 for e in sympy.Matrix(memory)) + assert not (memory.atoms(sympy.Function) & own) + # after one step the entering fluid has built a stress, so the memory is not zero + probe = np.array([[0.2, 0.3]]) + value = float(np.asarray(uw.function.evaluate(memory[0, 1], probe)).reshape(-1)[0]) + assert np.isfinite(value) and abs(value) > 1.0e-8, value + + +def test_a_varying_modulus_is_expanded_so_its_gradient_is_seen(): + """A modulus that varies in space is expanded in the memory stress (its + divergence then has the modulus gradient); the timestep stays a runtime + parameter.""" + from underworld3.function.expressions import UWexpression + uw.reset_default_model() + ns, v = developing_channel(steps=1, modulus_varies=True) + cm = ns.constitutive_model + containers = sympy.Matrix(ns._memory_stress()).atoms(UWexpression) + assert cm.Parameters.shear_modulus not in containers + assert containers and all(c.is_uw_constant() for c in containers) def test_a_viscous_fluid_has_no_memory_stress(): @@ -96,3 +118,32 @@ def test_the_developing_channel_keeps_its_recorded_flow(): ns.constitutive_model._carried_stress_sym(0)[0, 1], POINTS)).reshape(-1) assert np.allclose(ux, U_X, atol=1.0e-7), ux assert np.allclose(sxy, SIGMA_XY, atol=1.0e-9), sxy + + +# BASELINES: the semi-Lagrangian solver (momentum order 2, model order 1, +# solvent, log-conformation store): u_x and sigma_xy at POINTS (2026-09-28) +SLCN_U_X = (0.639781087, 0.840017720) +SLCN_SIGMA_XY = (-0.00330714240, 0.00221101846) + + +def test_the_semi_lagrangian_solver_keeps_its_recorded_developing_channel(): + """NavierStokesSLCN on the developing channel: a stress that varies along the + flow, a solvent, the log-conformation store, and momentum order 2 against a + first-order stress history, so every term of its momentum flux is live.""" + uw.reset_default_model() + ns, v = developing_channel(solver="slcn", stress_history="log_conformation") + ux = np.asarray(uw.function.global_evaluate(v.sym[0], POINTS)).reshape(-1) + sxy = np.asarray(uw.function.global_evaluate( + ns.constitutive_model._carried_stress_sym(0)[0, 1], POINTS)).reshape(-1) + assert np.allclose(ux, SLCN_U_X, atol=1.0e-7), ux + assert np.allclose(sxy, SLCN_SIGMA_XY, atol=1.0e-9), sxy + + +def test_an_integration_point_velocity_history_carries_a_viscoelastic_flow(): + """The solvent stress of the stored velocity needs its derivative; an + integration-point velocity store is read through its nodal snapshot.""" + uw.reset_default_model() + ns, v = developing_channel(solver="slcn", velocity_transport="backward_integration_points", + steps=2) + ux = np.asarray(uw.function.global_evaluate(v.sym[0], POINTS)).reshape(-1) + assert np.all(np.isfinite(ux)) and np.all(ux > 0.3), ux From 78e62a9a44977c63644c5a754781868520651ec0 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Tue, 29 Sep 2026 14:27:24 -1000 Subject: [PATCH 08/21] One NavierStokes and one AdvDiffusion, the transport chosen by argument uw.systems.NavierStokes(..., velocity_transport=...) carries the momentum on the grid ("eulerian", SUPG, the default) or semi-Lagrangianly ("backward_nodes", "backward_integration_points", "forward_integration_points", "forward_nodes"); stress_transport chooses the viscoelastic stress history as before. uw.systems.AdvDiffusion(..., transport=...) takes the same names. The composed solvers already held any history manager; the argument builds it through the shared _value_history. - the extrapolation level after a step is the velocity the step started from (it copied the history's first level: the departure-point velocity for a semi-Lagrangian history, and the wrong shape for an integration-point one); - the stored-level viscous stress reads an integration-point velocity store through its nodal snapshot (it failed to compile); - NavierStokesSLCN, NavierStokesSwarm (which carried no swarm) and AdvDiffusionSLCN resolve with a FutureWarning naming the replacement; the free-surface solver builds its composition solver by class, without it. Lid-driven cavity (Re 100): the four velocity histories within 0.6% of the grid scheme; the semi-Lagrangian path within 0.5% of the former NavierStokesSLCN (the stored-level stress is now rebuilt from the carried velocity); the viscoelastic developing channel reproduces it to 2e-8. test_1103 covers all five, test_1066 checks them against serial at np 3/4/6. Serial set 409 passed; tests/parallel np 4 as before (#801, #797). Underworld development team with AI support from Claude Code Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo --- docs/developer/subsystems/stress-transport.md | 54 ++++++++++--------- src/underworld3/systems/__init__.py | 37 +++++++++---- .../systems/advection_diffusion_eulerian.py | 37 ++++++++++--- src/underworld3/systems/free_surface.py | 2 +- .../systems/navier_stokes_eulerian.py | 34 ++++++++++-- src/underworld3/systems/solvers.py | 4 +- tests/test_1059_stress_transport.py | 3 +- ...t_1103_navier_stokes_velocity_transport.py | 34 +++++++----- ...t_1104_navier_stokes_supg_memory_stress.py | 33 ++++++------ 9 files changed, 161 insertions(+), 77 deletions(-) diff --git a/docs/developer/subsystems/stress-transport.md b/docs/developer/subsystems/stress-transport.md index c16b00ecd..b72d0f881 100644 --- a/docs/developer/subsystems/stress-transport.md +++ b/docs/developer/subsystems/stress-transport.md @@ -98,30 +98,36 @@ is linear, so it refuses a P2 velocity). ## With inertia -Both Navier-Stokes solvers take `stress_transport` and carry the stress history -through the same three steps as the Stokes family (prepare, carry once per -step, commit after the solve), and both read a log-conformation store through -the model's decode. - -- `uw.systems.NavierStokes` transports momentum on the grid with SUPG. The - SUPG term weights the strong momentum residual, and that residual includes - the divergence of the stress the history carries (at high Weissenberg number - the largest term of the balance), gradients of a spatially varying modulus - or viscosity included. An integration-point store has no derivative; its - nodal snapshot stands in, an O(dt) difference. The viscous term, the terms - that carry the velocity gradient itself (the objective rate's) and a - yielding viscosity need second derivatives and remain outside the residual. -- `uw.systems.NavierStokesSLCN` carries the velocity semi-Lagrangianly - (`velocity_transport=`). -- Both apply the momentum scheme's weights to the momentum flux (the theta - rule at first order, the new level alone for BDF): the new stress, and at a - stored level the carried stress plus the solvent stress of the velocity - there. The momentum's time order is the solver's; the stress history's is - the constitutive model's. - - -The particle history differs from serial by about 1e-5 at np 4 and 6 (np 3 -matches); the cause is open. +`uw.systems.NavierStokes` is one solver with two choices: + +```python +ns = uw.systems.NavierStokes(mesh, v, p, rho=rho, velocity_transport="eulerian") +ns.stress_transport = "forward_integration_points" # a viscoelastic material only +``` + +- `velocity_transport` carries the momentum: `"eulerian"` (the default) + assembles the advection on the mesh with SUPG; `"backward_nodes"`, + `"backward_integration_points"`, `"forward_integration_points"` or + `"forward_nodes"` carry the velocity semi-Lagrangianly (the forward schemes + at `order=1`; the forward integration-point fit is linear and refuses a P2 + velocity). +- `stress_transport` carries a viscoelastic stress, as for Stokes, with the + same three steps (prepare, carry once per step, commit after the solve), and + a log-conformation store is read through the model's decode. + +The momentum flux takes the momentum scheme's weights (the theta rule at +first order, the new level alone for BDF): the new stress, and at a stored +level the carried stress plus the solvent stress of the velocity there. With +`"eulerian"` the SUPG term weights the strong momentum residual, which +includes the divergence of the stress the history carries, gradients of a +spatially varying modulus or viscosity included. A store with no derivative +(the integration-point histories) is read there through its nodal snapshot, +an O(dt) difference. The viscous term, the terms that carry the velocity +gradient itself (the objective rate's) and a yielding viscosity need second +derivatives and remain outside the residual. + +The former `NavierStokesSLCN` and `AdvDiffusionSLCN` still work, with a +warning; `AdvDiffusion` takes `transport=` in the same way. ## The timestep is set by the wall strain rate, not the far-field Courant number diff --git a/src/underworld3/systems/__init__.py b/src/underworld3/systems/__init__.py index 300aea382..fa58d99da 100644 --- a/src/underworld3/systems/__init__.py +++ b/src/underworld3/systems/__init__.py @@ -18,14 +18,12 @@ Projection : class L2 projection of fields onto mesh variables. AdvDiffusion : class - Advection-diffusion composed from a DDt transport manager (the default - manager, EulerianSUPG, assembles implicit advection with SUPG). -AdvDiffusionSLCN : class - Advection-diffusion with semi-Lagrangian transport (flux history). + Advection-diffusion; ``transport=`` chooses how the field is carried: + "eulerian" (assembled, SUPG; the default) or a semi-Lagrangian history. NavierStokes : class - Navier-Stokes composed from a DDt transport manager (EulerianSUPG default). -NavierStokesSLCN : class - Navier-Stokes with semi-Lagrangian transport and a stress history. + Navier-Stokes; ``velocity_transport=`` chooses how the momentum is + carried ("eulerian" or a semi-Lagrangian history) and, for a + viscoelastic material, ``stress_transport`` how the stress is. Diffusion : class Pure diffusion (no advection). TransientDarcy : class @@ -68,7 +66,6 @@ # These are now implemented the same way using the ddt module -from .solvers import SNES_AdvectionDiffusion as AdvDiffusionSLCN from .solvers import SNES_AdvectionDiffusion_Swarm as AdvDiffusionSwarm # The generic names are the composing solvers: the transport (assembled SUPG # advection, or a semi-Lagrangian history) is the DDt manager they hold. @@ -83,8 +80,6 @@ from .solvers import SNES_Richards as Richards # These are now implemented the same way using the ddt module -from .solvers import SNES_NavierStokes as NavierStokesSwarm -from .solvers import SNES_NavierStokes as NavierStokesSLCN from .free_surface import FreeSurface @@ -104,3 +99,25 @@ # δ-continuation driver for hard viscoplastic (Drucker–Prager) yield from .yield_continuation import yield_continuation, YieldHomotopyControl from .solve_report import SolveReport + + +# Former names. One solver per equation, the transport chosen by argument: +# NavierStokes(velocity_transport=...), AdvDiffusion(transport=...). +_FORMER_NAMES = { + "NavierStokesSLCN": ("SNES_NavierStokes", + "uw.systems.NavierStokes(..., velocity_transport='backward_nodes')"), + "NavierStokesSwarm": ("SNES_NavierStokes", + "uw.systems.NavierStokes(..., velocity_transport='backward_nodes')"), + "AdvDiffusionSLCN": ("SNES_AdvectionDiffusion", + "uw.systems.AdvDiffusion(..., transport='backward_nodes')"), +} + + +def __getattr__(name): + if name in _FORMER_NAMES: + import warnings + from . import solvers + implementation, instead = _FORMER_NAMES[name] + warnings.warn(f"uw.systems.{name} is {instead}", FutureWarning, stacklevel=2) + return getattr(solvers, implementation) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/underworld3/systems/advection_diffusion_eulerian.py b/src/underworld3/systems/advection_diffusion_eulerian.py index 06d274943..3a39273c0 100644 --- a/src/underworld3/systems/advection_diffusion_eulerian.py +++ b/src/underworld3/systems/advection_diffusion_eulerian.py @@ -210,9 +210,19 @@ class SNES_AdvectionDiffusion_Composed(SNES_Scalar): solver into a semi-Lagrangian scheme on the field history, with no assembled advection and no stabilisation. A supplied manager fixes ``order`` and ``theta``. + transport : str, default "eulerian" + How the field is carried. ``"eulerian"`` assembles the advection on the + mesh with SUPG. A semi-Lagrangian history instead carries it along + ``V_fn``: ``"backward_nodes"``, ``"backward_integration_points"``, + ``"forward_integration_points"`` or ``"forward_nodes"``, named by the + ``trace`` and ``launch`` arguments of + :func:`~underworld3.systems.ddt.SemiLagrangian` (the forward schemes + carry one level, ``order=1``). restore_points_func, monotone_mode, old_frame_traceback, DFDt - Semi-Lagrangian arguments, accepted for drop-in compatibility and - ignored with a warning: there is no trace-back here. + Semi-Lagrangian arguments. With a semi-Lagrangian ``transport``, + ``monotone_mode`` and ``old_frame_traceback`` go to its history (a + scheme that does not take one refuses it); with ``"eulerian"`` they are + ignored with a warning, as are the others. Notes ----- @@ -245,16 +255,21 @@ def __init__( restore_points_func: Optional[Callable] = None, monotone_mode: Optional[str] = None, old_frame_traceback: bool = False, + transport: str = "eulerian", ): - if not u_Field.continuous: + eulerian = transport == "eulerian" + if eulerian and not u_Field.continuous: raise ValueError( "u_Field must be a continuous MeshVariable: the SUPG weak form " "is continuous Galerkin." ) + if DuDt is not None and not eulerian: + raise ValueError("transport chooses the DuDt the solver builds; it cannot " + "apply to a DuDt that is supplied") ignored = [name for name, value in ( ("restore_points_func", restore_points_func), - ("monotone_mode", monotone_mode), - ("old_frame_traceback", old_frame_traceback), + ("monotone_mode", monotone_mode if eulerian else None), + ("old_frame_traceback", old_frame_traceback if eulerian else None), ("DFDt", DFDt), ) if value] if ignored: @@ -291,7 +306,17 @@ def __init__( # The transport plugin: the history manager owns the time scheme, the # advecting velocity, the assembled advection and the stabilisation. - if DuDt is None: + if DuDt is None and not eulerian: + # a semi-Lagrangian history carries the field along V_fn; it has no + # assembled advection and no stabilisation + from underworld3.systems.solvers import _value_history + self.Unknowns.DuDt = _value_history( + transport, self.mesh, u_Field, V_fn, uw.VarType.SCALAR, order=order, + nodal_options=dict(verbose=verbose, bcs=self.essential_bcs, smoothing=0.0), + requested={k: v for k, v in (("monotone_mode", monotone_mode), + ("old_frame_traceback", old_frame_traceback)) if v}, + theta=theta) + elif DuDt is None: self.Unknowns.DuDt = EulerianSUPG_DDt( self.mesh, u_Field, diff --git a/src/underworld3/systems/free_surface.py b/src/underworld3/systems/free_surface.py index 6fd131772..38d5cbd41 100644 --- a/src/underworld3/systems/free_surface.py +++ b/src/underworld3/systems/free_surface.py @@ -730,7 +730,7 @@ def _build_composition_transport(self): # ~5.4% deformation; OFF holds T in [0,1] to 1e-3 through 17% deformation. monotone_mode="clamp", theta=0.5, old_frame_traceback=False, ) - self._comp_adv = uw.systems.AdvDiffusionSLCN( + self._comp_adv = uw.systems.solvers.SNES_AdvectionDiffusion( self.mesh, u_Field=self.composition, V_fn=self._adv_velocity.sym, order=1, DuDt=self._comp_ddt, ) diff --git a/src/underworld3/systems/navier_stokes_eulerian.py b/src/underworld3/systems/navier_stokes_eulerian.py index c83641f8b..6bd9a0998 100644 --- a/src/underworld3/systems/navier_stokes_eulerian.py +++ b/src/underworld3/systems/navier_stokes_eulerian.py @@ -131,6 +131,17 @@ class SNES_NavierStokes_Composed(SNES_Stokes): (2\nu)`; both are combined with the transient term as :math:`[(C_t c_0/\Delta t)^2 + \tau^{-2}]^{-1/2}` so the time step still caps them. The advective and viscous weights are not used by these two. + velocity_transport : str, default "eulerian" + How the momentum is carried. ``"eulerian"`` assembles the advection on + the mesh and stabilises it with SUPG (the parameters above). A + semi-Lagrangian history instead carries the velocity along its own + characteristics: ``"backward_nodes"``, ``"backward_integration_points"``, + ``"forward_integration_points"`` or ``"forward_nodes"``, named by the + ``trace`` and ``launch`` arguments of + :func:`~underworld3.systems.ddt.SemiLagrangian`; the forward schemes + carry one level (``order=1``), and the forward integration-point fit + is linear, so it refuses a P2 velocity. A viscoelastic stress history + is chosen separately, by ``stress_transport``. peclet_weight : float, default 4 A critical cell Péclet number. The SUPG term is multiplied by :math:`Pe^2 / (Pe^2 + Pe_c^2)`, :math:`Pe = |a| h / 2\nu`, so the @@ -180,6 +191,7 @@ def __init__( DuDt: Optional[_DDtBase] = None, DFDt=None, restore_points_func=None, + velocity_transport: str = "eulerian", ): if DFDt is not None: raise ValueError( @@ -237,7 +249,18 @@ def __init__( # The transport plugin: the history manager owns the time scheme, the # advecting velocity, the assembled advection and the stabilisation. # At the stored levels the momentum is carried by the stored velocity. - if DuDt is None: + if DuDt is not None and velocity_transport != "eulerian": + raise ValueError("velocity_transport chooses the DuDt the solver builds; it cannot " + "apply to a DuDt that is supplied") + if DuDt is None and velocity_transport != "eulerian": + # a semi-Lagrangian history carries the momentum along the velocity + # itself; it has no assembled advection and no stabilisation + from underworld3.systems.solvers import _value_history + self.Unknowns.DuDt = _value_history( + velocity_transport, self.mesh, u, u.sym, uw.VarType.VECTOR, order=order, + nodal_options=dict(verbose=verbose, bcs=self.essential_bcs, smoothing=0.0), + requested={}, theta=theta) + elif DuDt is None: self.Unknowns.DuDt = EulerianSUPG_DDt( self.mesh, u, @@ -471,7 +494,9 @@ def _viscous_flux(self): elastic stress history. BDF puts every spatial term at the new level and the question does not arise. """ - states = self.DuDt.states() + # a velocity store without a derivative is read through its snapshot + stand_in = self.DuDt._derivative_stand_ins() + states = [sympy.Matrix(u_k).xreplace(stand_in) for u_k in self.DuDt.states()] weights = self.DuDt.spatial_weights() if self.Unknowns.DFDt is not None: return self._theta_rule_flux(weights, states[1:]) @@ -641,8 +666,9 @@ def solve( if carries_stress: self._stress_history_post_solve(dt, verbose=verbose, evalf=False) - # Shift the extrapolation level, then the history. - self._u_prev.array[...] = self.DuDt.psi_star[0].array[...] + # Shift the extrapolation level (the velocity this step started from), + # then the history. + self._u_prev.array[...] = u_n self.DuDt.update_post_solve(dt, verbose=verbose) self.is_setup = True diff --git a/src/underworld3/systems/solvers.py b/src/underworld3/systems/solvers.py index 650812ce2..8403b26a2 100644 --- a/src/underworld3/systems/solvers.py +++ b/src/underworld3/systems/solvers.py @@ -4479,7 +4479,7 @@ class SNES_AdvectionDiffusion(SNES_Scalar): duDt = uw.systems.ddt.SemiLagrangian( mesh, T.sym, V_fn, vtype=uw.VarType.SCALAR, degree=T.degree, continuous=T.continuous, order=2) - adv = uw.systems.AdvDiffusionSLCN(mesh, T, V_fn, DuDt=duDt, order=1) + adv = uw.systems.AdvDiffusion(mesh, T, V_fn, DuDt=duDt, order=1) adv.DFDt.theta = 1.0 # flux implicit at n+1 (BDF2-consistent) A BDF2 stencil with a Crank-Nicolson flux (``order=2`` + ``theta=0.5``) @@ -5072,7 +5072,7 @@ def __init__( raise ValueError( "SNES_AdvectionDiffusion_Swarm needs a swarm to carry the history on; " "pass the material swarm (this solver does not create a private one). " - "Use AdvDiffusionSLCN for the mesh-based semi-Lagrangian scheme.") + "Use uw.systems.AdvDiffusion(..., transport='backward_nodes') for the mesh-based semi-Lagrangian scheme.") if int(step_averaging) < 1: raise ValueError(f"step_averaging must be >= 1, not {step_averaging!r}") if abs(float(theta) - 0.5) > 1e-12: diff --git a/tests/test_1059_stress_transport.py b/tests/test_1059_stress_transport.py index c34aadb43..3263b46c5 100644 --- a/tests/test_1059_stress_transport.py +++ b/tests/test_1059_stress_transport.py @@ -178,7 +178,8 @@ def _maxwell_shear(transport, order, steps=20, dt=0.1, integrator="bdf", solver= stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p, verbose=False) else: # The trace-back Navier-Stokes solver at negligible inertia: the same box. - stokes = uw.systems.NavierStokesSLCN(mesh, v, p, rho=1.0e-6, order=1) + stokes = uw.systems.NavierStokes(mesh, v, p, rho=1.0e-6, order=1, + velocity_transport="backward_nodes") stokes.bodyforce = sympy.Matrix([[0.0, 0.0]]) stokes.stress_transport = transport stokes.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( diff --git a/tests/test_1103_navier_stokes_velocity_transport.py b/tests/test_1103_navier_stokes_velocity_transport.py index e3884b5bc..fefbcdadf 100644 --- a/tests/test_1103_navier_stokes_velocity_transport.py +++ b/tests/test_1103_navier_stokes_velocity_transport.py @@ -1,4 +1,5 @@ -"""The Navier-Stokes velocity history carried by each semi-Lagrangian scheme. +"""The Navier-Stokes momentum carried by each transport: on the grid (SUPG) or by +each semi-Lagrangian scheme, chosen by ``velocity_transport``. A lid-driven cavity at Reynolds number 100 (lid speed 1, viscosity 0.01, density 1), first order, ten steps of 0.05 from rest: the momentum is advected, @@ -16,11 +17,12 @@ pytestmark = [pytest.mark.level_2, pytest.mark.tier_b] POINTS = np.array([[0.5, 0.75], [0.3, 0.5]]) -# BASELINES: horizontal velocity at POINTS after ten steps (2026-09-27) +# BASELINES: horizontal velocity at POINTS after ten steps (2026-09-29) CAVITY_U = { - "backward_nodes": (-0.1302979, -0.0553993), - "backward_integration_points": (-0.1300480, -0.0554152), - "forward_nodes": (-0.1300408, -0.0554839), + "eulerian": (-0.1290241, -0.0546760), + "backward_nodes": (-0.1296780, -0.0552098), + "backward_integration_points": (-0.1288954, -0.0548034), + "forward_nodes": (-0.1295085, -0.0550723), } @@ -29,8 +31,7 @@ def lid_driven_cavity(transport, steps=10, dt=0.05, cell_size=1.0 / 12): cellSize=cell_size, qdegree=3, regular=False) v = uw.discretisation.MeshVariable("U_cav", mesh, mesh.dim, degree=2) p = uw.discretisation.MeshVariable("P_cav", mesh, 1, degree=1) - ns = uw.systems.NavierStokesSLCN(mesh, velocityField=v, pressureField=p, rho=1.0, order=1, - velocity_transport=transport) + ns = uw.systems.NavierStokes(mesh, v, p, rho=1.0, order=1, velocity_transport=transport) ns.constitutive_model = uw.constitutive_models.ViscousFlowModel ns.constitutive_model.Parameters.shear_viscosity_0 = 0.01 ns.add_dirichlet_bc((1.0, 0.0), "Top") @@ -48,10 +49,12 @@ def lid_driven_cavity(transport, steps=10, dt=0.05, cell_size=1.0 / 12): def test_each_velocity_history_gives_its_recorded_cavity_flow(transport): uw.reset_default_model() kind, values = lid_driven_cavity(transport) - assert kind == "".join(w.capitalize() for w in transport.split("_")) + "SemiLagrangian" + expected_kind = ("EulerianSUPG" if transport == "eulerian" + else "".join(w.capitalize() for w in transport.split("_")) + "SemiLagrangian") + assert kind == expected_kind assert np.allclose(values, CAVITY_U[transport], atol=1.0e-6), (transport, values) - # the schemes differ by their transport error, a few parts in a thousand here - assert np.allclose(values, CAVITY_U["backward_nodes"], rtol=5.0e-3), (transport, values) + # the schemes differ by their transport error, about a percent here + assert np.allclose(values, CAVITY_U["eulerian"], rtol=2.0e-2), (transport, values) def test_the_forward_integration_point_fit_refuses_a_p2_velocity(): @@ -66,5 +69,12 @@ def test_the_forward_schemes_refuse_order_two(): v = uw.discretisation.MeshVariable("U_o2", mesh, mesh.dim, degree=2) p = uw.discretisation.MeshVariable("P_o2", mesh, 1, degree=1) with pytest.raises(NotImplementedError, match="order must be 1"): - uw.systems.NavierStokesSLCN(mesh, velocityField=v, pressureField=p, order=2, - velocity_transport="forward_nodes") + uw.systems.NavierStokes(mesh, v, p, order=2, velocity_transport="forward_nodes") + + +def test_the_former_solver_names_still_work_and_say_what_replaces_them(): + uw.reset_default_model() + with pytest.warns(FutureWarning, match="velocity_transport='backward_nodes'"): + assert uw.systems.NavierStokesSLCN is uw.systems.solvers.SNES_NavierStokes + with pytest.warns(FutureWarning, match="transport='backward_nodes'"): + assert uw.systems.AdvDiffusionSLCN is uw.systems.solvers.SNES_AdvectionDiffusion diff --git a/tests/test_1104_navier_stokes_supg_memory_stress.py b/tests/test_1104_navier_stokes_supg_memory_stress.py index b6d175c45..d3aa22aaf 100644 --- a/tests/test_1104_navier_stokes_supg_memory_stress.py +++ b/tests/test_1104_navier_stokes_supg_memory_stress.py @@ -26,7 +26,7 @@ def developing_channel(transport="forward_integration_points", steps=10, dt=0.05, cell=0.1, - solver="supg", stress_history="stress", velocity_transport="backward_nodes", + velocity_transport="eulerian", order=1, stress_history="stress", modulus_varies=False): eta_s, eta_p = BETA * ETA, (1 - BETA) * ETA lam = WI * H / U @@ -35,11 +35,8 @@ def developing_channel(transport="forward_integration_points", steps=10, dt=0.05 x, y = mesh.X v = uw.discretisation.MeshVariable("U_ch", mesh, 2, degree=2) p = uw.discretisation.MeshVariable("P_ch", mesh, 1, degree=1) - if solver == "supg": - ns = uw.systems.NavierStokes(mesh, v, p, rho=1.0) - else: - ns = uw.systems.NavierStokesSLCN(mesh, v, p, rho=1.0, order=2, - velocity_transport=velocity_transport) + ns = uw.systems.NavierStokes(mesh, v, p, rho=1.0, order=order, + velocity_transport=velocity_transport) ns.stress_transport = transport ns.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( ns.Unknowns, order=1, integrator="etd", objective_rate="upper_convected", @@ -120,18 +117,21 @@ def test_the_developing_channel_keeps_its_recorded_flow(): assert np.allclose(sxy, SIGMA_XY, atol=1.0e-9), sxy -# BASELINES: the semi-Lagrangian solver (momentum order 2, model order 1, -# solvent, log-conformation store): u_x and sigma_xy at POINTS (2026-09-28) -SLCN_U_X = (0.639781087, 0.840017720) -SLCN_SIGMA_XY = (-0.00330714240, 0.00221101846) +# BASELINES: semi-Lagrangian momentum (backward nodes, order 2) against a +# first-order stress history, solvent, log-conformation store: u_x and +# sigma_xy at POINTS (2026-09-29) +SLCN_U_X = (0.639781102, 0.840017737) +SLCN_SIGMA_XY = (-0.00330714249, 0.00221101845) -def test_the_semi_lagrangian_solver_keeps_its_recorded_developing_channel(): - """NavierStokesSLCN on the developing channel: a stress that varies along the - flow, a solvent, the log-conformation store, and momentum order 2 against a - first-order stress history, so every term of its momentum flux is live.""" +def test_semi_lagrangian_momentum_keeps_its_recorded_developing_channel(): + """Semi-Lagrangian momentum on the developing channel: a stress that varies + along the flow, a solvent, the log-conformation store, and momentum order 2 + against a first-order stress history, so every term of the momentum flux is + live.""" uw.reset_default_model() - ns, v = developing_channel(solver="slcn", stress_history="log_conformation") + ns, v = developing_channel(velocity_transport="backward_nodes", order=2, + stress_history="log_conformation") ux = np.asarray(uw.function.global_evaluate(v.sym[0], POINTS)).reshape(-1) sxy = np.asarray(uw.function.global_evaluate( ns.constitutive_model._carried_stress_sym(0)[0, 1], POINTS)).reshape(-1) @@ -143,7 +143,6 @@ def test_an_integration_point_velocity_history_carries_a_viscoelastic_flow(): """The solvent stress of the stored velocity needs its derivative; an integration-point velocity store is read through its nodal snapshot.""" uw.reset_default_model() - ns, v = developing_channel(solver="slcn", velocity_transport="backward_integration_points", - steps=2) + ns, v = developing_channel(velocity_transport="backward_integration_points", steps=2) ux = np.asarray(uw.function.global_evaluate(v.sym[0], POINTS)).reshape(-1) assert np.all(np.isfinite(ux)) and np.all(ux > 0.3), ux From bf4e663e856c84c1a945a73dfddce429ed133d10 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Fri, 2 Oct 2026 17:52:45 +1000 Subject: [PATCH 09/21] Store smoothing on every semi-Lagrangian stress history; a slope limiter for the forward integration-point fit store_smoothing (alpha = c h^2 in the projection that stores a new flux) moves to a mixin shared by the backward and forward integration-point histories and the forward nodal history. The forward integration-point history keeps flux_smoothing as an absolute override and refuses both at once. The forward integration-point fit gains fit_limiter (Barth-Jespersen: keep the centroid value, scale the slope so no dof leaves the range of what arrived in the cell) and records the unlimited fit's overshoot either way. A cell whose arrivals bunch in a corner passes the conditioning test and extrapolates its slope across the cell; on the viscoelastic cylinder (SUPG velocity, log-conformation store) one such cell in the near wake put tau_II = 4 where nothing above 0.8 arrived, and the next solve diverged. Underworld development team with AI support from Claude Code Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo --- src/underworld3/systems/ddt.py | 176 +++++++++++++++------- tests/test_1059_stress_transport.py | 67 +++++++- tests/test_1060_stress_store_smoothing.py | 4 +- 3 files changed, 186 insertions(+), 61 deletions(-) diff --git a/src/underworld3/systems/ddt.py b/src/underworld3/systems/ddt.py index df0364a5f..1c14cbbc2 100644 --- a/src/underworld3/systems/ddt.py +++ b/src/underworld3/systems/ddt.py @@ -4770,7 +4770,62 @@ def _storage_components(vtype, shape): return [(i, j) for i in range(shape[0]) for j in range(shape[1])] -class BackwardIntegrationPointsSemiLagrangian(_DDtBase): +class _StoreSmoothingMixin: + """The Laplacian smoothing of the projection that stores a new flux, shared by + the semi-Lagrangian stress histories whose store cycle has no dissipation of + its own at the cell scale.""" + + _store_smoothing = 0.0 + + @property + def store_smoothing(self) -> float: + r"""Coefficient :math:`c` of the Laplacian term in the store projection, + :math:`\alpha = c\,h(\mathbf{x})^2` with :math:`h` the local cell size. + + Every step the new flux is L2-projected onto the continuous snapshot and + read back at the points. That cycle is a consistent-mass Galerkin + transport of the carried stress and has no dissipation at the cell + scale, so below Courant one a cell-scale mode of the stress grows from + round-off at a rate :math:`\gamma` set by the elastic feedback (about + 2.4 per unit time on the Maxwell Waters-King start-up, 1.8 with a + solvent fraction of 0.2, and negligible at 0.59). The term + :math:`\alpha\nabla^2` in the projection multiplies wavenumber + :math:`k` by :math:`1/(1+\alpha k^2)` once per step, so the mode is held + when :math:`\alpha (\pi/h)^2 \gtrsim \gamma\,\Delta t`, i.e. + :math:`\alpha \approx \gamma\,\Delta t\,(h/\pi)^2`. Measured on the + 1/32 mesh at :math:`\Delta t = 0.01`: 1e-5 holds it for eight time + units at 0.1% on the peak, 3e-5 holds it unconditionally at 0.5%, + 1e-4 costs 3%. In units of the mesh cell-size field (RMS vertex-to- + centroid distance, about 2h/3 on triangles) that is :math:`c` between + 0.03 and 0.07; the irregular mesh needs 0.07. Zero (the default) is + the plain projection. Only the stress store is smoothed; the forcing + history is not. + + The forward histories have the same cycle and take the same dose: the + forward integration-point history smooths the projection its launch + points read the new flux from, the forward nodal history the projection + that commits the new flux to its store. On a graded mesh with one time + step the large cells run well below Courant one, which is where the + mode grows. + """ + return self._store_smoothing + + @store_smoothing.setter + def store_smoothing(self, value): + value = float(value) + if value < 0.0: + raise ValueError(f"store_smoothing must be >= 0, got {value}") + self._store_smoothing = value + + def _store_smoothing_alpha(self): + """The smoothing the store projection uses this step: a field, so the + dose follows the local cell on a graded mesh.""" + if self._store_smoothing <= 0.0: + return 0.0 + return self._store_smoothing * self.mesh.cell_size() ** 2 + + +class BackwardIntegrationPointsSemiLagrangian(_StoreSmoothingMixin, _DDtBase): r"""Semi-Lagrangian history stored at the mesh integration points. The history slots ``psi_star[k]`` are @@ -5008,39 +5063,6 @@ def state(self, s: "DDtIntegrationPointState") -> None: self._restore_core_state(s, am_theta=self.theta) self._history_committed = bool(s.history_committed) - @property - def store_smoothing(self) -> float: - r"""Coefficient :math:`c` of the Laplacian term in the store projection, - :math:`\alpha = c\,h(\mathbf{x})^2` with :math:`h` the local cell size. - - Every step the new flux is L2-projected onto the continuous snapshot and - read back at the points. That cycle is a consistent-mass Galerkin - transport of the carried stress and has no dissipation at the cell - scale, so below Courant one a cell-scale mode of the stress grows from - round-off at a rate :math:`\gamma` set by the elastic feedback (about - 2.4 per unit time on the Maxwell Waters-King start-up, 1.8 with a - solvent fraction of 0.2, and negligible at 0.59). The term - :math:`\alpha\nabla^2` in the projection multiplies wavenumber - :math:`k` by :math:`1/(1+\alpha k^2)` once per step, so the mode is held - when :math:`\alpha (\pi/h)^2 \gtrsim \gamma\,\Delta t`, i.e. - :math:`\alpha \approx \gamma\,\Delta t\,(h/\pi)^2`. Measured on the - 1/32 mesh at :math:`\Delta t = 0.01`: 1e-5 holds it for eight time - units at 0.1% on the peak, 3e-5 holds it unconditionally at 0.5%, - 1e-4 costs 3%. In units of the mesh cell-size field (RMS vertex-to- - centroid distance, about 2h/3 on triangles) that is :math:`c` between - 0.03 and 0.07; the irregular mesh needs 0.07. Zero (the default) is - the plain projection. Only the stress store is smoothed; the forcing - history is not. - """ - return self._store_smoothing - - @store_smoothing.setter - def store_smoothing(self, value): - value = float(value) - if value < 0.0: - raise ValueError(f"store_smoothing must be >= 0, got {value}") - self._store_smoothing = value - def carried_tensors(self, level: int = 0): """The carried history as one tensor per point, non-dimensional, with the points: ``(values[n, d, d], coords[n, cdim])``. The integration-point @@ -5057,13 +5079,6 @@ def carried_tensors(self, level: int = 0): points = np.asarray(history.integration_points).reshape(-1, self.mesh.cdim) return values, points - def _store_smoothing_alpha(self): - """The smoothing the store projection uses this step: a field, so the - dose follows the local cell on a graded mesh.""" - if self._store_smoothing <= 0.0: - return 0.0 - return self._store_smoothing * self.mesh.cell_size() ** 2 - def spatial_weights(self): """As the base class, except that at ``theta = 1`` the old-level weights, identically zero, are returned as literals. A runtime @@ -5375,7 +5390,7 @@ def update_post_solve(self, dt, evalf=False, verbose=False, **_ignored): self._n_solves_completed += 1 -class ForwardIntegrationPointsSemiLagrangian(_DDtBase): +class ForwardIntegrationPointsSemiLagrangian(_StoreSmoothingMixin, _DDtBase): r"""Semi-Lagrangian history carried forward from a fixed set of launch points inside the cells, read by the weak form through a per-cell fit. @@ -5399,7 +5414,7 @@ class ForwardIntegrationPointsSemiLagrangian(_DDtBase): It is, like the integration-point history, a consistently transported scheme with no dissipation of its own at the cell scale: below Courant one on a Maxwell element a cell-scale mode grows from round-off, and the - read-back projection needs :attr:`flux_smoothing` for the same reason and + read-back projection needs :attr:`store_smoothing` for the same reason and at the same dose as the integration-point store. See :doc:`/developer/subsystems/stress-transport`. @@ -5432,6 +5447,8 @@ def __init__( order: int = 1, theta: float = 0.5, units=None, + store_smoothing: float = 0.0, + fit_limiter: bool = False, **_unsupported, ): super().__init__() @@ -5490,11 +5507,14 @@ def __init__( self._launch_geometry = self._geometry_stamp() self._bface_cell, self._bface_centroid, self._bface_normal = self._boundary_faces() # The flux read at the launch points, through a continuous P1 projection. - # `flux_smoothing` is the Laplacian coefficient of that projection, a - # number or a field (length^2): c * mesh.cell_size()**2 with c between - # 0.03 and 0.07 is the dose the integration-point store needs below - # Courant one on a Maxwell element, and this cycle needs the same. + # Its Laplacian coefficient is `store_smoothing` (c, with alpha = c h^2 + # from the local cell) or, as an absolute override, `flux_smoothing` + # (a number or a field, length^2); not both. self.flux_smoothing = 0.0 + self.store_smoothing = store_smoothing + # limit each cell's slope to the range of what arrived (see _fit_arrivals) + self.fit_limiter = bool(fit_limiter) + self._fit_overshoot, self._fit_overshoot_cells = 0.0, 0 self._flux_var = uw.discretisation.MeshVariable( f"flux_fwd_{inst}", mesh, (1, self.num_components), vtype=VarType.MATRIX, degree=1, continuous=True, varsymbol=rf"{{ F^{{\mathrm{{nodal}}}}_{{ [{inst}] }} }}") @@ -5620,7 +5640,11 @@ def _evaluate_at_launch(self, expr): self.mesh, u_Field=self._flux_var, n_components=self.num_components) self._flux_projection.linear_solver(rtol=_HISTORY_PROJECTION_TOLERANCE) self._flux_projection.uw_function = sympy.Matrix([[expr[i, j] for (i, j) in self._components]]) - self._flux_projection.smoothing = self.flux_smoothing + if self._store_smoothing > 0.0 and not (isinstance(self.flux_smoothing, (int, float)) and self.flux_smoothing == 0.0): + raise ValueError("ForwardIntegrationPointsSemiLagrangian: set store_smoothing (c, alpha = c h^2) " + "or flux_smoothing (alpha), not both") + self._flux_projection.smoothing = (self._store_smoothing_alpha() if self._store_smoothing > 0.0 + else self.flux_smoothing) self._flux_projection.solve(zero_init_guess=True) out = np.empty_like(self._launch_values) for k in range(self.num_components): @@ -5652,7 +5676,11 @@ def _fit_arrivals(self, X, values, cell=None, inflow=None): missing share filled with the inflow value at its own dofs, weighted by that share: the state of the part of the cell nothing has reached is the incoming fluid. A cell whose arrivals cannot determine a linear - fit keeps its previous one. + fit keeps its previous one. With :attr:`fit_limiter` the slope of each + fit is scaled so that no dof leaves the range of the values that reached + the cell; ``_fit_overshoot`` records, with or without it, the largest + excursion of the unlimited fit beyond that range (relative to the + largest range) and ``_fit_overshoot_cells`` how many cells had one. """ mesh = self.mesh d = mesh.dim @@ -5683,6 +5711,7 @@ def strict(P): ndof = dofs.shape[0] // ncell if ncell else 0 dofs = dofs.reshape(ncell, ndof, mesh.cdim) Adof = np.concatenate([np.ones((ncell, ndof, 1)), (dofs[:, :, :d] - centroid[:, None, :]) / h[:, None, None]], axis=2) + short = np.zeros(0, dtype=bool) if self._inflow_value is not None and inflow is not None: deficit = np.clip(self._cell_measure - received, 0.0, None) * inflow fed = deficit > 0.0 @@ -5706,7 +5735,40 @@ def strict(P): fit_ok = ev[:, 0] > 1.0e-6 * np.maximum(ev[:, -1], 1.0e-300) beta = np.zeros_like(R) beta[fit_ok] = np.linalg.solve(M[fit_ok], R[fit_ok]) - fitted = np.einsum("cqi,cik->cqk", Adof, beta).reshape(ncell * ndof, self.num_components) + fitted = np.einsum("cqi,cik->cqk", Adof, beta) + # The range of what reached each cell (and the inflow it was fed): a fit + # whose arrivals cover a corner of the cell is determined but extrapolates + # its slope across the rest, and can put a value at a dof far outside + # anything carried there (a new extremum the exponential decode of a + # log-conformation store then amplifies). + lo = np.full((ncell, self.num_components), np.inf) + hi = np.full((ncell, self.num_components), -np.inf) + np.minimum.at(lo, cell, values) + np.maximum.at(hi, cell, values) + if self._inflow_value is not None and inflow is not None and short.any(): + lo[sel] = np.minimum(lo[sel], filled[short].min(axis=1)) + hi[sel] = np.maximum(hi[sel], filled[short].max(axis=1)) + spread = np.where(fit_ok[:, None], hi - lo, 0.0) + excess = np.maximum(fitted - hi[:, None, :], lo[:, None, :] - fitted).max(axis=1) + excess = np.where(fit_ok[:, None], np.clip(excess, 0.0, None), 0.0) + # overshoot of the fit beyond the arrivals' range, relative to the largest + # range in the cell set (a diagnostic, recorded with or without the limiter) + scale = max(float(spread.max()) if spread.size else 0.0, 1.0e-300) + self._fit_overshoot = float(excess.max()) / scale if excess.size else 0.0 + self._fit_overshoot_cells = int(np.count_nonzero(excess.max(axis=1) > 1.0e-12 * scale)) if excess.size else 0 + if self.fit_limiter and fit_ok.any(): + # Barth-Jespersen: keep the cell value at the centroid (clipped to the + # range), scale the slope by the largest factor that keeps every dof + # inside the range + c0 = np.clip(beta[:, 0, :], lo, hi) + delta = fitted - c0[:, None, :] + with np.errstate(divide="ignore", invalid="ignore"): + room = np.where(delta > 0.0, (hi - c0)[:, None, :] / delta, + np.where(delta < 0.0, (lo - c0)[:, None, :] / delta, np.inf)) + phi = np.clip(np.nan_to_num(room.min(axis=1), nan=1.0, posinf=1.0), 0.0, 1.0) + limited = c0[:, None, :] + phi[:, None, :] * delta + fitted = np.where(fit_ok[:, None, None], limited, fitted) + fitted = fitted.reshape(ncell * ndof, self.num_components) rows_ok = np.repeat(fit_ok, ndof) for k in range(self.num_components): column = np.array(self.psi_star[0].data[:, k]) @@ -5780,7 +5842,7 @@ class DDtForwardNodesState(_DDtCoreState): psi_star_var_names: list[str] = field(default_factory=list) -class ForwardNodesSemiLagrangian(_DDtBase): +class ForwardNodesSemiLagrangian(_StoreSmoothingMixin, _DDtBase): r"""Forward semi-Lagrangian history launched from where the field is known. A field is known exactly at its own nodes (they are its unknowns) and, since @@ -5803,7 +5865,9 @@ class ForwardNodesSemiLagrangian(_DDtBase): elements; the next step launches from that projected field, which is a polynomial inside each element like any other. The integration-point counterpart, :class:`ForwardIntegrationPointsSemiLagrangian`, launches a - flux from the quadrature points where it is formed. + flux from the quadrature points where it is formed. The commit is a + consistent-mass projection with no dissipation at the cell scale, so a + stress carried below Courant one needs :attr:`store_smoothing`. An arrival on a face or vertex shared by several cells (a node on a no-slip wall arrives where it started) is fitted in every one of them. In @@ -5825,16 +5889,20 @@ class ForwardNodesSemiLagrangian(_DDtBase): Degree of the store and of the per-cell fit; use the field's own degree. units : optional Units of the carried quantity (see :func:`_history_units`). + store_smoothing : float + Smoothing of the projection that commits a flux (see + :attr:`store_smoothing`); a carried field is not smoothed. """ applies_inflow_value = True instances = 0 def __init__(self, mesh, psi_fn, V_fn, vtype=VarType.SCALAR, degree=1, varsymbol=None, - order=1, theta=0.5, units=None, **_unsupported): + order=1, theta=0.5, units=None, store_smoothing=0.0, **_unsupported): super().__init__() if order != 1: raise NotImplementedError("ForwardNodesSemiLagrangian carries one level; order must be 1") + self.store_smoothing = store_smoothing if mesh.cdim != mesh.dim: raise NotImplementedError("ForwardNodesSemiLagrangian fits in the embedding coordinates; no manifolds") if _unsupported: @@ -6039,7 +6107,7 @@ def update_post_solve(self, dt, evalf=False, verbose=False, **_ignored): def commit_flux_to_history(self, flux, verbose=False): """Project the new flux onto the store, where the next step launches it.""" - projected = self._project_nodally(flux, verbose=verbose) + projected = self._project_nodally(flux, smoothing=self._store_smoothing_alpha(), verbose=verbose) self.psi_star[0].data[:, :] = np.asarray(projected.data) self._history_committed = True diff --git a/tests/test_1059_stress_transport.py b/tests/test_1059_stress_transport.py index 3263b46c5..956db7c60 100644 --- a/tests/test_1059_stress_transport.py +++ b/tests/test_1059_stress_transport.py @@ -759,15 +759,23 @@ def test_the_elastic_timestep_is_the_safety_factor_over_the_shear_rate(transport assert _one_shear_step(transport, dt=1.0).constitutive_model.max_elastic_timestep() == float("inf") -def test_the_store_smoothing_is_the_coefficient_times_the_local_cell_size_squared(): - stokes = _one_shear_step("backward_integration_points", dt=1.0) +def _store_projection(history): + """The projection a history stores its new flux through.""" + if hasattr(history, "_flux_projection"): + return history._flux_projection + return history._nodal_projections["flux"][1] + + +@pytest.mark.parametrize("transport", ["backward_integration_points", "forward_integration_points", "forward_nodes"]) +def test_the_store_smoothing_is_the_coefficient_times_the_local_cell_size_squared(transport): + stokes = _one_shear_step(transport, dt=1.0) history = stokes.DFDt assert history.store_smoothing == 0.0 - assert history._nodal_projections["flux"][1].smoothing == 0.0 + assert _store_projection(history).smoothing == 0.0 history.store_smoothing = 0.05 stokes.solve(timestep=1.0, zero_init_guess=False) # the projection's smoothing is now a field: c times the cell-size field squared - alpha = history._nodal_projections["flux"][1].smoothing + alpha = _store_projection(history).smoothing x0 = np.array([[0.1, 0.1]]) h = float(np.asarray(uw.function.evaluate(stokes.mesh.cell_size(), x0)).reshape(-1)[0]) a = float(np.asarray(uw.function.evaluate(alpha, x0)).reshape(-1)[0]) @@ -776,6 +784,57 @@ def test_the_store_smoothing_is_the_coefficient_times_the_local_cell_size_square history.store_smoothing = -1.0 +def test_the_forward_fit_limiter_keeps_each_cell_inside_the_range_of_its_arrivals(): + """Arrivals bunched in one corner of a cell, with a steep tilt between them: + the unlimited linear fit extrapolates that tilt across the cell and puts + values at the far dofs well outside anything that arrived; the limited fit + keeps every dof inside the arrivals' range and the cell's centroid value.""" + stokes = _one_shear_step("forward_integration_points", dt=1.0) + history = stokes.DFDt + mesh = stokes.mesh + d, ncomp = mesh.dim, history.num_components + centroid = np.asarray(mesh._centroids)[0, :d] + h = float(np.sqrt(history._cell_measure[0])) + cell = 0 + # the cell's own launch points pulled 95% of the way to its first one: a + # corner cluster that still spans two directions (the fit is determined) + own = history._launch[history._launch_cell == cell][:, :d] + X = own[0] + 0.05 * (own - own[0]) + rng = np.random.default_rng(1) + values = np.repeat((X[:, :1] - centroid[0]) / h, ncomp, axis=1) + 0.01 * rng.standard_normal((X.shape[0], ncomp)) + + def fit(limit): + history.fit_limiter = limit + # every other cell keeps its own launch points, so only cell 0 is unusual + keep = history._launch_cell != cell + Xall = np.vstack([history._launch[keep], X]) + vall = np.vstack([history._launch_values[keep], values]) + call = np.concatenate([history._launch_cell[keep], np.full(X.shape[0], cell)]) + w = history._launch_weights + history._launch_weights = np.concatenate([w[keep], w[~keep]]) + try: + history._fit_arrivals(Xall, vall, cell=call) + finally: + history._launch_weights = w + ndof = history.psi_star[0].data.shape[0] // history._cell_measure.size + return np.array(history.psi_star[0].data[cell * ndof:(cell + 1) * ndof]) + + free = fit(False) + assert history._fit_overshoot > 0.5 # recorded without the limiter + assert (free.max() > values.max() + 0.5) or (free.min() < values.min() - 0.5) + limited = fit(True) + assert limited.max() <= values.max() + 1.0e-12 + assert limited.min() >= values.min() - 1.0e-12 + + +def test_the_forward_history_refuses_two_smoothing_doses(): + stokes = _one_shear_step("forward_integration_points", dt=1.0) + stokes.DFDt.store_smoothing = 0.05 + stokes.DFDt.flux_smoothing = 1.0e-4 + with pytest.raises(ValueError, match="not both"): + stokes.solve(timestep=1.0, zero_init_guess=False) + + @pytest.mark.parametrize("transport", list(KINDS)) def test_the_semi_lagrangian_navier_stokes_carries_every_stress_history(transport): """NavierStokesSLCN takes stress_transport as the Stokes family does, and at diff --git a/tests/test_1060_stress_store_smoothing.py b/tests/test_1060_stress_store_smoothing.py index f5bfc3346..d45b348db 100644 --- a/tests/test_1060_stress_store_smoothing.py +++ b/tests/test_1060_stress_store_smoothing.py @@ -50,10 +50,8 @@ def waters_king_start_up(store_smoothing, res=16, dt=0.0125, t_end=2.0, transpor ns.add_dirichlet_bc((0.0, 0.0), "Top"); ns.add_dirichlet_bc((0.0, 0.0), "Bottom") ns.add_dirichlet_bc((sympy.oo, 0.0), "Left"); ns.add_dirichlet_bc((sympy.oo, 0.0), "Right") ns.bodyforce = sympy.Matrix([[G, 0.0]]); ns.tolerance = 1e-6 - if transport == "backward_integration_points": + if transport in ("backward_integration_points", "forward_integration_points", "forward_nodes"): ns.DFDt.store_smoothing = store_smoothing - elif transport == "forward_integration_points": - ns.DFDt.flux_smoothing = store_smoothing * mesh.cell_size() ** 2 # The content has to be read after the trace-back and before the solve: after # the store the point values are a P1 field sampled at the points and the # cell-scale part is zero by construction, whatever the run is doing. From a62aa9d30d92817b5c7c8a81956557b52e7f149b Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sat, 3 Oct 2026 15:25:57 +1000 Subject: [PATCH 10/21] Forward integration-point history: a global L2 projection of the arrivals in place of the per-cell fit ParticleL2Projector (utilities/particle_projection.py) projects scattered weighted values onto continuous P1 by one weighted least-squares solve: the finite-element mass matrix with the points as its quadrature rule. A node is set by every point in the patch of cells around it, so it is interpolated rather than extrapolated from one cell's points; a weak pull towards the previous field keeps a node nothing reached; the system is assembled per owning cell and summed across partition seams through the local-to-global ADD, so the answer does not depend on the partition. ForwardIntegrationPointsSemiLagrangian gains reconstruction="cell" (the per-cell fit, the default) or "global" (the projection, read back at the store's interior dofs). On the viscoelastic cylinder (Re 200, Wi 0.5, log-conformation store, SUPG velocity) the per-cell fit needed store_smoothing 0.07 to survive and still failed at t 5.2; the global projection runs to t 8 with no smoothing and no limiter, the field's excursion beyond the carried range steady at 5%, and lands within 8% in drag and 12% in lift of the Eulerian stress history at the same Strouhal number. Underworld development team with AI support from Claude Code Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo --- src/underworld3/systems/ddt.py | 112 +++++++++++- .../utilities/particle_projection.py | 167 ++++++++++++++++++ tests/test_1059_stress_transport.py | 3 +- tests/test_1067_particle_projection.py | 92 ++++++++++ 4 files changed, 369 insertions(+), 5 deletions(-) create mode 100644 src/underworld3/utilities/particle_projection.py create mode 100644 tests/test_1067_particle_projection.py diff --git a/src/underworld3/systems/ddt.py b/src/underworld3/systems/ddt.py index 1c14cbbc2..4163cf914 100644 --- a/src/underworld3/systems/ddt.py +++ b/src/underworld3/systems/ddt.py @@ -5449,6 +5449,7 @@ def __init__( units=None, store_smoothing: float = 0.0, fit_limiter: bool = False, + reconstruction: str = "cell", **_unsupported, ): super().__init__() @@ -5515,6 +5516,12 @@ def __init__( # limit each cell's slope to the range of what arrived (see _fit_arrivals) self.fit_limiter = bool(fit_limiter) self._fit_overshoot, self._fit_overshoot_cells = 0.0, 0 + self._global_projector = None + self._dof_lambda, self._dof_lambda_dm = None, None + # the pull towards the previous field, relative to the mass matrix, in + # the global projection: what keeps a node no arrival reached + self.global_eps = 1.0e-8 + self.reconstruction = reconstruction self._flux_var = uw.discretisation.MeshVariable( f"flux_fwd_{inst}", mesh, (1, self.num_components), vtype=VarType.MATRIX, degree=1, continuous=True, varsymbol=rf"{{ F^{{\mathrm{{nodal}}}}_{{ [{inst}] }} }}") @@ -5534,6 +5541,34 @@ def _launch_values(self, values): # round trip per component) self._launch_var.data[:, :] = np.asarray(values).reshape(self._launch.shape[0], self.num_components) + @property + def reconstruction(self) -> str: + """How the arrivals become the field the weak form reads. + + ``"cell"``: a weighted least-squares linear fit to the arrivals in each + cell (discontinuous; :attr:`fit_limiter` optionally bounds it). + ``"global"``: one L2 projection of every arrival onto the continuous P1 + field, the arrivals as its quadrature points (see + :class:`~underworld3.utilities.particle_projection.ParticleL2Projector`); + no per-cell fit, and a node is interpolated from the arrivals on every + side of it. The store keeps its layout either way (the global field is + written to every cell's copy of a vertex), so the choice can change + between steps, after a restart included.""" + return self._reconstruction + + @reconstruction.setter + def reconstruction(self, value): + if value not in ("cell", "global"): + raise ValueError(f"reconstruction is 'cell' or 'global', not {value!r}") + if value == "global" and self.fit_limiter: + raise ValueError("fit_limiter applies to the per-cell fit (reconstruction='cell'), " + "not the global projection") + if value == "global" and self._global_projector is None: + from underworld3.utilities.particle_projection import ParticleL2Projector + self._global_projector = ParticleL2Projector(self.mesh, rtol=_HISTORY_PROJECTION_TOLERANCE) + self._dof_lambda = None + self._reconstruction = value + @property def state(self) -> "DDtForwardState": return DDtForwardState( @@ -5680,13 +5715,15 @@ def _fit_arrivals(self, X, values, cell=None, inflow=None): fit is scaled so that no dof leaves the range of the values that reached the cell; ``_fit_overshoot`` records, with or without it, the largest excursion of the unlimited fit beyond that range (relative to the - largest range) and ``_fit_overshoot_cells`` how many cells had one. + largest range) and ``_fit_overshoot_cells`` how many cells had one, for + the last carried fit. """ mesh = self.mesh d = mesh.dim npar = d + 1 ncell = self._cell_measure.size w = self._launch_weights + carried = cell is None if cell is None: # Ownership is by strict containment (face tolerance zero), not the # evaluation locator's slab: a point a hair across a seam face would @@ -5697,6 +5734,10 @@ def strict(P): return np.asarray(mesh._get_closest_local_cells_internal(P, tol=0.0), dtype=int).reshape(-1) X, (values, w), cell, self._n_relocated = _hand_arrivals_to_owners( X, (values, w), strict) + if self.reconstruction == "global": + self._project_arrivals(np.asarray(X), np.asarray(values), np.asarray(w), np.asarray(cell, dtype=int), + inflow, carried) + return centroid = np.asarray(mesh._centroids)[:, :d] h = np.sqrt(self._cell_measure) if d == 2 else np.cbrt(self._cell_measure) # centred on the cell and scaled by its size, so the constant is c0 and the @@ -5754,8 +5795,11 @@ def strict(P): # overshoot of the fit beyond the arrivals' range, relative to the largest # range in the cell set (a diagnostic, recorded with or without the limiter) scale = max(float(spread.max()) if spread.size else 0.0, 1.0e-300) - self._fit_overshoot = float(excess.max()) / scale if excess.size else 0.0 - self._fit_overshoot_cells = int(np.count_nonzero(excess.max(axis=1) > 1.0e-12 * scale)) if excess.size else 0 + if carried: + # the carry's fit (the refit at the launch points after a commit + # does not overwrite it) + self._fit_overshoot = float(excess.max()) / scale if excess.size else 0.0 + self._fit_overshoot_cells = int(np.count_nonzero(excess.max(axis=1) > 1.0e-12 * scale)) if excess.size else 0 if self.fit_limiter and fit_ok.any(): # Barth-Jespersen: keep the cell value at the centroid (clipped to the # range), scale the slope by the largest factor that keeps every dof @@ -5775,6 +5819,68 @@ def strict(P): column[rows_ok] = fitted[rows_ok, k] self.psi_star[0].data[:, k] = column + def _project_arrivals(self, X, values, w, cell, inflow, carried): + """The arrivals projected (L2, globally) onto the continuous P1 store: + no per-cell fit; every node is set by all the arrivals in its patch. A + boundary cell that received less than it holds is topped up with the + inflow value at its vertices, weighted by the shortfall, as the + per-cell fit does.""" + mesh = self.mesh + d = mesh.dim + pj = self._global_projector + if pj._dm is not mesh.dm: + pj._build() + ncell = self._cell_measure.size + if self._inflow_value is not None and inflow is not None: + received = np.bincount(cell, weights=w, minlength=ncell) + deficit = np.clip(self._cell_measure - received, 0.0, None) * inflow + # evaluate is collective: every rank reads the inflow value at every + # boundary-cell vertex, whether or not any of its cells is short + bcells = np.flatnonzero(np.isin(np.arange(ncell), self._bface_cell)) + verts = pj._Xv[bcells].reshape(-1, d) + filled = np.column_stack([ + _to_nondim_ndarray(uw.function.evaluate(self._inflow_record()[i, j], verts)).reshape(-1) + for (i, j) in self._components]).reshape(bcells.size, d + 1, self.num_components) + short = deficit[bcells] > 0.0 + if short.any(): + sel = bcells[short] + X = np.vstack([X[:, :d], pj._Xv[sel].reshape(-1, d)]) + values = np.vstack([values, filled[short].reshape(-1, self.num_components)]) + w = np.concatenate([w, np.repeat(deficit[sel] / (d + 1), d + 1)]) + cell = np.concatenate([cell, np.repeat(sel, d + 1)]) + # The store is discontinuous P1 with its dofs INSIDE each cell (not at + # the vertices): a dof takes the continuous field interpolated at its + # own position, and the previous vertex values are read back through + # each cell's own linear interpolant (then averaged over the cells + # sharing the vertex). + if self._dof_lambda is None or self._dof_lambda_dm is not mesh.dm: + dofs = np.asarray(self.psi_star[0].coords_nd).reshape(ncell, -1, mesh.cdim)[:, :, :d] + ndof = dofs.shape[1] + self._dof_lambda = pj.barycentric(dofs.reshape(-1, d), np.repeat(np.arange(ncell), ndof)).reshape(ncell, ndof, d + 1) + self._dof_lambda_inv = np.linalg.inv(self._dof_lambda) + self._dof_lambda_dm = mesh.dm + L = self._dof_lambda + store = np.asarray(self.psi_star[0].data).reshape(ncell, -1, self.num_components) + at_vertices = np.einsum("cij,cjk->cik", self._dof_lambda_inv, store) # (cell, vertex, comp) + old = np.zeros((pj.n_local_rows, self.num_components)) + count = np.zeros(pj.n_local_rows) + np.add.at(old, pj._rows.reshape(-1), at_vertices.reshape(-1, self.num_components)) + np.add.at(count, pj._rows.reshape(-1), 1.0) + old /= np.maximum(count, 1.0)[:, None] + u = pj.project(X, values, w, cell, old=old, eps=self.global_eps) + self.psi_star[0].data[:, :] = np.einsum("cqi,cik->cqk", L, u[pj._rows]).reshape(-1, self.num_components) + if carried: + # a projection is not bounded: the largest excursion of the nodal + # field beyond the range of everything carried, relative to that range + comm = uw.mpi.comm + vmax = comm.allreduce(float(values.max()) if values.size else -np.inf, op=uw.MPI.MAX) + vmin = comm.allreduce(float(values.min()) if values.size else np.inf, op=uw.MPI.MIN) + umax = comm.allreduce(float(u.max()) if u.size else -np.inf, op=uw.MPI.MAX) + umin = comm.allreduce(float(u.min()) if u.size else np.inf, op=uw.MPI.MIN) + span = max(vmax - vmin, 1.0e-300) + self._fit_overshoot = max(umax - vmax, vmin - umin, 0.0) / span + self._fit_overshoot_cells = -1 + def initialise_history(self): """Start from the current field: its values at the launch points, and their fit, so ``bdf()`` is zero on the first step. A history already diff --git a/src/underworld3/utilities/particle_projection.py b/src/underworld3/utilities/particle_projection.py new file mode 100644 index 000000000..3b2f84b46 --- /dev/null +++ b/src/underworld3/utilities/particle_projection.py @@ -0,0 +1,167 @@ +r"""Global L2 projection of scattered, weighted values onto a continuous P1 field. + +Given points :math:`\mathbf{x}_p` with values :math:`v_p` and weights :math:`w_p` +(each point's share of the domain), the continuous linear field +:math:`u = \sum_i u_i \phi_i` that best fits them in the weighted least-squares +sense solves + +.. math:: + + \Big(\sum_p w_p\,\phi_i(\mathbf{x}_p)\,\phi_j(\mathbf{x}_p) + + \varepsilon M_{ij} + \alpha K_{ij}\Big)\,u_j + = \sum_p w_p\,\phi_i(\mathbf{x}_p)\,v_p + \varepsilon M_{ij}\,u^{\mathrm{old}}_j . + +The first term is the finite-element mass matrix with the points as its +quadrature rule: when the points are the integration points of the mesh, or +those points moved by an incompressible flow, the weights are a quadrature rule +and the system is the ordinary L2 projection. There is no per-cell fit: every +nodal value is set by all the points in the patch of cells around the node, so +the node is interpolated from data on every side of it rather than extrapolated +from one cell's points. + +A node whose patch received no points has an empty row. The term +:math:`\varepsilon M (u - u^{\mathrm{old}})` (:math:`M` the finite-element mass +matrix) keeps such a node at its previous value and is negligible where the +patch is sampled; :math:`\alpha K` (:math:`K` the stiffness matrix, :math:`\alpha` +per cell, length squared) is the optional gradient penalty of the history +projections' ``store_smoothing``. + +Simplex meshes. In parallel each point is contributed by the rank that owns its +cell (the mesh is distributed without overlap); the shared nodes on a partition +seam sum their contributions through PETSc's local-to-global ADD, so the system, +and to the solver tolerance the answer, does not depend on the partition. +""" + +import numpy as np +from petsc4py import PETSc + +import underworld3 as uw + + +class ParticleL2Projector: + """Weighted least-squares projection of scattered values onto continuous P1. + + Parameters + ---------- + mesh : Mesh + A simplex mesh. + rtol : float + Relative tolerance of the conjugate-gradient solve. + """ + + instances = 0 + + def __init__(self, mesh, rtol=1.0e-12): + ParticleL2Projector.instances += 1 + self.mesh = mesh + self.rtol = rtol + # the scalar layout the matrix and vectors live on; a P1 variable of any + # shape keeps its rows in the same vertex order (rows = section offsets) + self._var = uw.discretisation.MeshVariable( + f"_pl2_{ParticleL2Projector.instances}", mesh, 1, degree=1, continuous=True) + self._dm = None + + def _build(self): + """The cell-to-row map, the element matrices and the solver, for the mesh + DM as it is now (adding a variable rebuilds the DM, so this is redone + whenever the DM changes).""" + mesh = self.mesh + dm = mesh.dm + d = mesh.dim + c0, c1 = dm.getHeightStratum(0) + v0, v1 = dm.getDepthStratum(0) + _, self._sub = dm.createSubDM(self._var.field_id) + sec = self._sub.getLocalSection() + csec = dm.getCoordinateSection() + coords = dm.getCoordinatesLocal().array.reshape(-1, mesh.cdim) + cells = [] + for c in range(c0, c1): + verts = [p for p in dm.getTransitiveClosure(c)[0] if v0 <= p < v1] + if len(verts) != d + 1: + raise NotImplementedError("ParticleL2Projector needs a simplex mesh") + cells.append(verts) + cells = np.asarray(cells, dtype=np.int64).reshape(-1, d + 1) + self._rows = np.array([[sec.getOffset(int(p)) for p in row] for row in cells], + dtype=np.int32).reshape(-1, d + 1) + Xv = np.array([[coords[csec.getOffset(int(p)) // mesh.cdim, :d] for p in row] for row in cells]) + self._Xv = Xv + self._x0 = Xv[:, 0, :] if cells.size else np.zeros((0, d)) + # barycentric coordinates: lambda_{1..d} = Tinv (x - x0), lambda_0 = 1 - sum + T = np.transpose(Xv[:, 1:, :] - Xv[:, :1, :], (0, 2, 1)) if cells.size else np.zeros((0, d, d)) + self._Tinv = np.linalg.inv(T) if cells.size else T + measure = np.abs(np.linalg.det(T)) / (1.0 if d == 1 else 2.0 if d == 2 else 6.0) if cells.size else np.zeros(0) + self.cell_measure = measure + # element matrices: consistent mass and stiffness of the linear simplex + nv = d + 1 + base = (np.ones((nv, nv)) + np.eye(nv)) / ((d + 1) * (d + 2)) + self._Me = measure[:, None, None] * base[None, :, :] + G = np.concatenate([-self._Tinv.sum(axis=1, keepdims=True), self._Tinv], axis=1) # grad lambda, (nc, nv, d) + self._Ke = measure[:, None, None] * np.einsum("cid,cjd->cij", G, G) + self._A = self._sub.createMatrix() + self._A.setOption(PETSc.Mat.Option.NEW_NONZERO_ALLOCATION_ERR, False) + self._ksp = PETSc.KSP().create(comm=dm.comm) + self._ksp.setType("cg") + self._ksp.getPC().setType("jacobi") + self._ksp.setTolerances(rtol=self.rtol, atol=0.0, max_it=10000) + self._lb = self._sub.createLocalVector() + self._gb = self._sub.createGlobalVector() + self._gx = self._sub.createGlobalVector() + self._lx = self._sub.createLocalVector() + self.n_local_rows = self._lb.getSize() + self._dm = dm + + def barycentric(self, X, cell): + """Barycentric coordinates of points ``X`` in their cells, shape (n, d+1).""" + if self._dm is not self.mesh.dm: + self._build() + d = self.mesh.dim + lam = np.einsum("nij,nj->ni", self._Tinv[cell], X[:, :d] - self._x0[cell]) + return np.concatenate([1.0 - lam.sum(axis=1, keepdims=True), lam], axis=1) + + def project(self, X, values, weights, cell, old=None, eps=1.0e-8, alpha=None): + """The projected field at the local rows, shape (n_local_rows, ncomponents). + + ``X`` (n, d), ``values`` (n, k), ``weights`` (n,) and ``cell`` (n,), the + owning local cell of each point; ``old`` (n_local_rows, k), the previous + field, which unreached nodes keep; ``eps`` scales the pull towards it + relative to the finite-element mass matrix; ``alpha`` (ncell,) the + gradient penalty per cell.""" + if self._dm is not self.mesh.dm: + self._build() + values = np.asarray(values, dtype=float) + # no points at all (a rank whose patches nothing reached): the component + # count comes from the previous field + k = values.shape[1] if values.ndim == 2 else (np.asarray(old).shape[1] if old is not None else 1) + values = values.reshape(len(X), k) + ncell = self._rows.shape[0] + lam = self.barycentric(np.asarray(X, dtype=float), cell) + Me = np.zeros_like(self._Me) + Re = np.zeros((ncell, self._rows.shape[1], k)) + w = np.asarray(weights, dtype=float) + np.add.at(Me, cell, w[:, None, None] * lam[:, :, None] * lam[:, None, :]) + np.add.at(Re, cell, w[:, None, None] * lam[:, :, None] * values[:, None, :]) + if eps > 0.0: + Me = Me + eps * self._Me + if old is not None: + Re = Re + eps * np.einsum("cij,cjk->cik", self._Me, np.asarray(old)[self._rows]) + if alpha is not None: + Me = Me + np.asarray(alpha, dtype=float).reshape(-1)[:, None, None] * self._Ke + A = self._A + A.zeroEntries() + for c in range(ncell): + A.setValuesLocal(self._rows[c], self._rows[c], Me[c], addv=PETSc.InsertMode.ADD_VALUES) + A.assemble() + self._ksp.setOperators(A) + out = np.zeros((self.n_local_rows, k)) + for j in range(k): + self._lb.zeroEntries() + b = self._lb.getArray() + np.add.at(b, self._rows.ravel(), Re[:, :, j].ravel()) + self._lb.setArray(b) + self._gb.zeroEntries() + self._sub.localToGlobal(self._lb, self._gb, addv=PETSc.InsertMode.ADD_VALUES) + self._gx.zeroEntries() + self._ksp.solve(self._gb, self._gx) + self._sub.globalToLocal(self._gx, self._lx) + out[:, j] = self._lx.getArray() + return out diff --git a/tests/test_1059_stress_transport.py b/tests/test_1059_stress_transport.py index 956db7c60..763d8ce21 100644 --- a/tests/test_1059_stress_transport.py +++ b/tests/test_1059_stress_transport.py @@ -809,11 +809,10 @@ def fit(limit): keep = history._launch_cell != cell Xall = np.vstack([history._launch[keep], X]) vall = np.vstack([history._launch_values[keep], values]) - call = np.concatenate([history._launch_cell[keep], np.full(X.shape[0], cell)]) w = history._launch_weights history._launch_weights = np.concatenate([w[keep], w[~keep]]) try: - history._fit_arrivals(Xall, vall, cell=call) + history._fit_arrivals(Xall, vall) # located, as a carry is finally: history._launch_weights = w ndof = history.psi_star[0].data.shape[0] // history._cell_measure.size diff --git a/tests/test_1067_particle_projection.py b/tests/test_1067_particle_projection.py new file mode 100644 index 000000000..64d19b692 --- /dev/null +++ b/tests/test_1067_particle_projection.py @@ -0,0 +1,92 @@ +"""The global L2 projection of scattered points onto continuous P1 +(ParticleL2Projector), and the forward integration-point history that uses it in +place of its per-cell fit (reconstruction='global').""" +import numpy as np +import pytest +import sympy +import underworld3 as uw +from underworld3.utilities.particle_projection import ParticleL2Projector + +pytestmark = [pytest.mark.level_2, pytest.mark.tier_b] + + +def test_a_linear_field_sampled_anywhere_in_the_cells_is_projected_exactly(): + mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.1, qdegree=3) + pj = ParticleL2Projector(mesh) + pj._build() + ncell = pj._rows.shape[0] + rng = np.random.default_rng(7 + uw.mpi.rank) + lam = rng.dirichlet(np.ones(3), size=(ncell, 5)) + X = np.einsum("cpi,cid->cpd", lam, pj._Xv).reshape(-1, 2) + cell = np.repeat(np.arange(ncell), 5) + w = np.repeat(pj.cell_measure / 5, 5) + + def f(P): + return np.c_[1.0 + 2.0 * P[:, 0] - 3.0 * P[:, 1], 0.5 * P[:, 0]] + + u = pj.project(X, f(X), w, cell, old=np.zeros((pj.n_local_rows, 2)), eps=1.0e-12) + assert np.abs(u - f(np.asarray(pj._var.coords))).max() < 1.0e-9 + + +def test_a_node_no_point_reached_keeps_its_previous_value(): + mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.25, qdegree=3) + pj = ParticleL2Projector(mesh) + pj._build() + old = np.full((pj.n_local_rows, 1), 7.0) + u = pj.project(np.zeros((0, 2)), np.zeros((0, 1)), np.zeros(0), np.zeros(0, dtype=int), old=old, eps=1.0e-8) + assert np.abs(u - 7.0).max() < 1.0e-9 + + +@pytest.mark.parametrize("reconstruction", ["cell", "global"]) +def test_both_reconstructions_carry_the_maxwell_shear_stress(reconstruction): + # eta = G = dt = 1, wall speed 0.5: tau_xy = 1 - 2^-n after n steps + mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(-1.0, -0.5), maxCoords=(1.0, 0.5), cellSize=0.125, qdegree=3) + v = uw.discretisation.MeshVariable(f"U_pp_{reconstruction}", mesh, 2, degree=2) + p = uw.discretisation.MeshVariable(f"P_pp_{reconstruction}", mesh, 1, degree=1) + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + stokes.stress_transport = "forward_integration_points" + stokes.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + stokes.Unknowns, order=1, integrator="bdf") + cm = stokes.constitutive_model + cm.Parameters.shear_viscosity_0 = 1.0 + cm.Parameters.shear_modulus = 1.0 + cm.Parameters.dt_elastic = 1.0 + stokes.add_dirichlet_bc((0.5, 0.0), "Top") + stokes.add_dirichlet_bc((-0.5, 0.0), "Bottom") + stokes.add_dirichlet_bc((sympy.oo, 0.0), "Left") + stokes.add_dirichlet_bc((sympy.oo, 0.0), "Right") + stokes.tolerance = 1.0e-8 + stokes.DFDt.reconstruction = reconstruction + for n in range(1, 5): + stokes.solve(timestep=1.0, zero_init_guess=False) + xy = np.asarray(stokes.DFDt.psi_star[0].data)[:, 2] + assert np.abs(xy - (1.0 - 0.5 ** n)).max() < 1.0e-6 + + +@pytest.mark.parametrize("reconstruction", ["cell", "global"]) +def test_both_reconstructions_hold_a_linear_stress_at_the_stores_own_dofs(reconstruction): + """A stress varying linearly in space, read at the launch points and + reconstructed, is reproduced exactly at the store's dofs, which sit INSIDE + each cell (a uniform field cannot tell a dof at a vertex from one inside the + cell; writing vertex values into interior dofs steepens every cell).""" + mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.2, qdegree=3) + v = uw.discretisation.MeshVariable(f"U_pp_lin_{reconstruction}", mesh, 2, degree=2) + history = uw.systems.ddt.ForwardIntegrationPointsSemiLagrangian( + mesh, sympy.Matrix.zeros(2, 2), v.sym, uw.VarType.SYM_TENSOR, reconstruction=reconstruction) + + def stress(P): + return np.c_[1.0 + P[:, 0], 2.0 - 3.0 * P[:, 1], 0.5 * P[:, 0] + 0.25 * P[:, 1]] + + history._fit_arrivals(history._launch, stress(history._launch), cell=history._launch_cell) + dofs = np.asarray(history.psi_star[0].coords_nd) + # (the global projection's weak pull towards the previous, zero, field is 1e-8 of the mass) + assert np.abs(np.asarray(history.psi_star[0].data) - stress(dofs)).max() < 1.0e-6 + + +def test_the_global_reconstruction_refuses_the_per_cell_limiter(): + mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.25, qdegree=3) + v = uw.discretisation.MeshVariable("U_pp_lim", mesh, 2, degree=2) + history = uw.systems.ddt.ForwardIntegrationPointsSemiLagrangian( + mesh, sympy.Matrix.zeros(2, 2), v.sym, uw.VarType.SYM_TENSOR, fit_limiter=True) + with pytest.raises(ValueError, match="per-cell fit"): + history.reconstruction = "global" From ea5b2027413fdd27979059620057435f97d837ec Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sat, 3 Oct 2026 20:49:50 +1000 Subject: [PATCH 11/21] Forward nodal history: the global projection as an alternative to the per-cell fit ForwardNodesSemiLagrangian gains reconstruction="global": every arrival, from the nodes and the interior lattice, projected onto the degree-1 store in one L2 solve through ParticleL2Projector, each launch point weighing its share of the cell it launched from and an arrival on a shared face counted once (the multiplicity is summed over the ranks). The excursion of the field beyond the carried range is recorded as for the integration-point flavour, and a shared helper measures it. Underworld development team with AI support from Claude Code Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo --- src/underworld3/systems/ddt.py | 103 ++++++++++++++++++++++--- tests/test_1067_particle_projection.py | 33 +++++++- 2 files changed, 120 insertions(+), 16 deletions(-) diff --git a/src/underworld3/systems/ddt.py b/src/underworld3/systems/ddt.py index 4163cf914..e71e78169 100644 --- a/src/underworld3/systems/ddt.py +++ b/src/underworld3/systems/ddt.py @@ -5390,6 +5390,18 @@ def update_post_solve(self, dt, evalf=False, verbose=False, **_ignored): self._n_solves_completed += 1 +def _range_excursion(values, u): + """How far the reconstructed field ``u`` leaves the range of the carried + ``values``, over every rank, relative to that range: a projection is not + bounded, and this is the measure of how much it is not.""" + comm = uw.mpi.comm + vmax = comm.allreduce(float(values.max()) if values.size else -np.inf, op=uw.MPI.MAX) + vmin = comm.allreduce(float(values.min()) if values.size else np.inf, op=uw.MPI.MIN) + umax = comm.allreduce(float(u.max()) if u.size else -np.inf, op=uw.MPI.MAX) + umin = comm.allreduce(float(u.min()) if u.size else np.inf, op=uw.MPI.MIN) + return max(umax - vmax, vmin - umin, 0.0) / max(vmax - vmin, 1.0e-300) + + class ForwardIntegrationPointsSemiLagrangian(_StoreSmoothingMixin, _DDtBase): r"""Semi-Lagrangian history carried forward from a fixed set of launch points inside the cells, read by the weak form through a per-cell fit. @@ -5870,15 +5882,7 @@ def _project_arrivals(self, X, values, w, cell, inflow, carried): u = pj.project(X, values, w, cell, old=old, eps=self.global_eps) self.psi_star[0].data[:, :] = np.einsum("cqi,cik->cqk", L, u[pj._rows]).reshape(-1, self.num_components) if carried: - # a projection is not bounded: the largest excursion of the nodal - # field beyond the range of everything carried, relative to that range - comm = uw.mpi.comm - vmax = comm.allreduce(float(values.max()) if values.size else -np.inf, op=uw.MPI.MAX) - vmin = comm.allreduce(float(values.min()) if values.size else np.inf, op=uw.MPI.MIN) - umax = comm.allreduce(float(u.max()) if u.size else -np.inf, op=uw.MPI.MAX) - umin = comm.allreduce(float(u.min()) if u.size else np.inf, op=uw.MPI.MIN) - span = max(vmax - vmin, 1.0e-300) - self._fit_overshoot = max(umax - vmax, vmin - umin, 0.0) / span + self._fit_overshoot = _range_excursion(values, u) self._fit_overshoot_cells = -1 def initialise_history(self): @@ -6004,7 +6008,8 @@ class ForwardNodesSemiLagrangian(_StoreSmoothingMixin, _DDtBase): instances = 0 def __init__(self, mesh, psi_fn, V_fn, vtype=VarType.SCALAR, degree=1, varsymbol=None, - order=1, theta=0.5, units=None, store_smoothing=0.0, **_unsupported): + order=1, theta=0.5, units=None, store_smoothing=0.0, reconstruction="cell", + **_unsupported): super().__init__() if order != 1: raise NotImplementedError("ForwardNodesSemiLagrangian carries one level; order must be 1") @@ -6047,6 +6052,13 @@ def __init__(self, mesh, psi_fn, V_fn, vtype=VarType.SCALAR, degree=1, varsymbol self._owned = _owned_rows(self.psi_star[0]) self._n_relocated = 0 self._n_v = 2 + self._global_projector = None + self._launch_weights = None + self._fit_overshoot = 0.0 + # the pull towards the previous field, relative to the mass matrix, in + # the global projection: what keeps a node no arrival reached + self.global_eps = 1.0e-8 + self.reconstruction = reconstruction self._init_coefficient_expressions(1, self.theta, with_exp=True) self._register_with_default_model() @@ -6058,6 +6070,55 @@ def psi_fn(self): def psi_fn(self, new_fn): self._psi_fn = new_fn if isinstance(new_fn, sympy.Matrix) else sympy.Matrix([[new_fn]]) + @property + def reconstruction(self) -> str: + """How the arrivals become the carried field. + + ``"cell"``: a polynomial of the field's degree fitted to the arrivals in + each cell, then projected onto the continuous store. ``"global"``: one L2 + projection of every arrival straight onto the store, the arrivals as the + quadrature points (see + :class:`~underworld3.utilities.particle_projection.ParticleL2Projector`): + no per-cell fit, each node interpolated from the arrivals on every side + of it. The global projection is linear, so it needs a degree-1 store. + Each launch point weighs its share of the cell it launched from; an + arrival on a face shared by several cells is counted once.""" + return self._reconstruction + + @reconstruction.setter + def reconstruction(self, value): + if value not in ("cell", "global"): + raise ValueError(f"reconstruction is 'cell' or 'global', not {value!r}") + if value == "global": + if self.degree != 1: + raise NotImplementedError("the global projection is linear (P1); a degree " + f"{self.degree} forward-nodes history keeps reconstruction='cell'") + if self._global_projector is None: + from underworld3.utilities.particle_projection import ParticleL2Projector + self._global_projector = ParticleL2Projector(self.mesh, rtol=_HISTORY_PROJECTION_TOLERANCE) + self._reconstruction = value + + def _weights_of_launch(self): + """Each launch point's share of the domain: the lattice points share + their cell's measure, a node the measure of a cell that contains it; the + order is the launch order (owned nodes, then the lattice).""" + if self._launch_weights is None: + pj = self._global_projector + if pj._dm is not self.mesh.dm: + pj._build() + measure = pj.cell_measure + ncell = measure.size + n_lat = self._interior.shape[0] // max(ncell, 1) + lattice_w = np.repeat(measure / max(n_lat, 1), n_lat) + nodes = self._nodes() + point, cell, _ = self._projector.containing_cells(nodes) + node_cell = np.zeros(nodes.shape[0], dtype=int) + node_cell[point[::-1]] = cell[::-1] # any containing cell (the first) + node_w = measure[node_cell] / max(n_lat, 1) + self._launch_weights = (node_w, lattice_w) + node_w, lattice_w = self._launch_weights + return np.concatenate([node_w[self._owned], lattice_w]) + @property def state(self) -> "DDtForwardNodesState": return DDtForwardNodesState( @@ -6120,9 +6181,15 @@ def _arrivals_by_cell(self, X, values): q, qcell, _ = pj.containing_cells(offered_X) own = (q >= mine_from) & (q < mine_from + n_mine) self._n_relocated = int(np.unique(q[~own]).size) + # how many cells, over every rank, each offered point was taken into: + # the global projection counts a point once, whatever it touches + count = np.bincount(q, minlength=offered_X.shape[0]).astype(float) + if uw.mpi.size > 1: + count = uw.mpi.comm.allreduce(count, op=uw.MPI.SUM) return (np.concatenate([X[point[keep]], offered_X[q]], axis=0), np.concatenate([values[point[keep]], offered_v[q]], axis=0), - np.concatenate([cell[keep], qcell])) + np.concatenate([cell[keep], qcell]), + np.concatenate([np.ones(int(keep.sum())), count[q]])) def _reconstruct(self, arrivals, values, source): """Fit the arrivals in each cell at the field's degree, and project the @@ -6131,7 +6198,19 @@ def _reconstruct(self, arrivals, values, source): reading one cell's fit would depend on which cell (and in parallel which rank) the node was located in. A cell nothing reached keeps the field it launched (``source``).""" - arrivals, values, cells = self._arrivals_by_cell(arrivals, values) + if self.reconstruction == "global": + # the weights travel with the values, as one more column + w = self._weights_of_launch() + arrivals, vw, cells, mult = self._arrivals_by_cell(arrivals, np.column_stack([values, w])) + values, w = vw[:, :-1], vw[:, -1] / mult + pj = self._global_projector + if pj._dm is not self.mesh.dm: + pj._build() + old = np.array(self.psi_star[0].data) + u = pj.project(arrivals[:, :self.mesh.dim], values, w, cells, old=old, eps=self.global_eps) + self._fit_overshoot = _range_excursion(values, u) + return u + arrivals, values, cells, _ = self._arrivals_by_cell(arrivals, values) launched = self._values_at(source, np.asarray(self._fit_var.coords_nd)) self._fit_var.data[:, :] = self._projector.fit(arrivals, values, old=launched, cell_local=True, cells=cells) diff --git a/tests/test_1067_particle_projection.py b/tests/test_1067_particle_projection.py index 64d19b692..aaba867d7 100644 --- a/tests/test_1067_particle_projection.py +++ b/tests/test_1067_particle_projection.py @@ -37,14 +37,15 @@ def test_a_node_no_point_reached_keeps_its_previous_value(): assert np.abs(u - 7.0).max() < 1.0e-9 +@pytest.mark.parametrize("transport", ["forward_integration_points", "forward_nodes"]) @pytest.mark.parametrize("reconstruction", ["cell", "global"]) -def test_both_reconstructions_carry_the_maxwell_shear_stress(reconstruction): +def test_both_reconstructions_carry_the_maxwell_shear_stress(transport, reconstruction): # eta = G = dt = 1, wall speed 0.5: tau_xy = 1 - 2^-n after n steps mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(-1.0, -0.5), maxCoords=(1.0, 0.5), cellSize=0.125, qdegree=3) - v = uw.discretisation.MeshVariable(f"U_pp_{reconstruction}", mesh, 2, degree=2) - p = uw.discretisation.MeshVariable(f"P_pp_{reconstruction}", mesh, 1, degree=1) + v = uw.discretisation.MeshVariable(f"U_pp_{transport[8]}{reconstruction}", mesh, 2, degree=2) + p = uw.discretisation.MeshVariable(f"P_pp_{transport[8]}{reconstruction}", mesh, 1, degree=1) stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) - stokes.stress_transport = "forward_integration_points" + stokes.stress_transport = transport stokes.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( stokes.Unknowns, order=1, integrator="bdf") cm = stokes.constitutive_model @@ -63,6 +64,30 @@ def test_both_reconstructions_carry_the_maxwell_shear_stress(reconstruction): assert np.abs(xy - (1.0 - 0.5 ** n)).max() < 1.0e-6 +def test_the_forward_nodes_global_projection_reproduces_a_linear_field_from_its_launch_set(): + """The forward-nodes history launched from the nodes and the interior lattice, + carried by a zero velocity (the arrivals are the launch points), projected + globally: a linear field comes back exactly at the nodes.""" + mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.2, qdegree=3) + x, y = mesh.X + T = uw.discretisation.MeshVariable("T_fwn_lin", mesh, 1, degree=1) + T.data[:, 0] = 1.0 + 2.0 * np.asarray(T.coords)[:, 0] - 3.0 * np.asarray(T.coords)[:, 1] + history = uw.systems.ddt.ForwardNodesSemiLagrangian(mesh, T.sym, sympy.Matrix([[0.0, 0.0]]), + reconstruction="global") + history.update_pre_solve(0.1) + expected = 1.0 + 2.0 * np.asarray(T.coords)[:, 0] - 3.0 * np.asarray(T.coords)[:, 1] + assert np.abs(np.asarray(history.psi_star[0].data)[:, 0] - expected).max() < 1.0e-6 + assert history._fit_overshoot < 1.0e-6 + + +def test_the_forward_nodes_global_projection_needs_a_linear_store(): + mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.25, qdegree=3) + v = uw.discretisation.MeshVariable("U_fwn_p2", mesh, 2, degree=2) + with pytest.raises(NotImplementedError, match="degree 2"): + uw.systems.ddt.ForwardNodesSemiLagrangian(mesh, v.sym, v.sym, uw.VarType.VECTOR, degree=2, + reconstruction="global") + + @pytest.mark.parametrize("reconstruction", ["cell", "global"]) def test_both_reconstructions_hold_a_linear_stress_at_the_stores_own_dofs(reconstruction): """A stress varying linearly in space, read at the launch points and From 42d0aab2a320ad6afa76c19b26c55b718b61542b Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sun, 4 Oct 2026 08:48:47 +1100 Subject: [PATCH 12/21] ParticleL2Projector at degree 2, so the forward nodal velocity history can use the global projection The projector takes degree=2 on triangles: rows over the vertices then the edges (the layout of any continuous P2 variable), the quadratic simplex basis in barycentric coordinates, and element mass and stiffness matrices by Dunavant's degree-4 rule. A quadratic field sampled anywhere in the cells is projected exactly, serially and in parallel. ForwardNodesSemiLagrangian accepts reconstruction="global" at degree 2, so a P2 velocity carried forward from its nodes is reconstructed by one projection instead of a quadratic fit per cell. Underworld development team with AI support from Claude Code Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo --- src/underworld3/systems/ddt.py | 7 +- .../utilities/particle_projection.py | 111 +++++++++++++++--- tests/test_1067_particle_projection.py | 42 ++++++- 3 files changed, 134 insertions(+), 26 deletions(-) diff --git a/src/underworld3/systems/ddt.py b/src/underworld3/systems/ddt.py index e71e78169..06314aa07 100644 --- a/src/underworld3/systems/ddt.py +++ b/src/underworld3/systems/ddt.py @@ -6090,12 +6090,13 @@ def reconstruction(self, value): if value not in ("cell", "global"): raise ValueError(f"reconstruction is 'cell' or 'global', not {value!r}") if value == "global": - if self.degree != 1: - raise NotImplementedError("the global projection is linear (P1); a degree " + if self.degree not in (1, 2): + raise NotImplementedError("the global projection is P1 or P2; a degree " f"{self.degree} forward-nodes history keeps reconstruction='cell'") if self._global_projector is None: from underworld3.utilities.particle_projection import ParticleL2Projector - self._global_projector = ParticleL2Projector(self.mesh, rtol=_HISTORY_PROJECTION_TOLERANCE) + self._global_projector = ParticleL2Projector(self.mesh, degree=self.degree, + rtol=_HISTORY_PROJECTION_TOLERANCE) self._reconstruction = value def _weights_of_launch(self): diff --git a/src/underworld3/utilities/particle_projection.py b/src/underworld3/utilities/particle_projection.py index 3b2f84b46..19502e317 100644 --- a/src/underworld3/utilities/particle_projection.py +++ b/src/underworld3/utilities/particle_projection.py @@ -1,4 +1,4 @@ -r"""Global L2 projection of scattered, weighted values onto a continuous P1 field. +r"""Global L2 projection of scattered, weighted values onto a continuous P1 or P2 field. Given points :math:`\mathbf{x}_p` with values :math:`v_p` and weights :math:`w_p` (each point's share of the domain), the continuous linear field @@ -26,7 +26,7 @@ per cell, length squared) is the optional gradient penalty of the history projections' ``store_smoothing``. -Simplex meshes. In parallel each point is contributed by the rank that owns its +Simplex meshes; degree 2 on triangles. In parallel each point is contributed by the rank that owns its cell (the mesh is distributed without overlap); the shared nodes on a partition seam sum their contributions through PETSc's local-to-global ADD, so the system, and to the solver tolerance the answer, does not depend on the partition. @@ -38,27 +38,49 @@ import underworld3 as uw +# Dunavant's degree-4 rule on the triangle, in barycentric coordinates with the +# weights summing to one: exact for the P2 mass matrix (degree 4) and stiffness +_A1, _B1, _W1 = 0.445948490915965, 0.108103018168070, 0.223381589678011 +_A2, _B2, _W2 = 0.091576213509771, 0.816847572980459, 0.109951743655322 +_TRI_RULE = ( + np.array([[_B1, _A1, _A1], [_A1, _B1, _A1], [_A1, _A1, _B1], + [_B2, _A2, _A2], [_A2, _B2, _A2], [_A2, _A2, _B2]]), + np.array([_W1, _W1, _W1, _W2, _W2, _W2]), +) + + class ParticleL2Projector: - """Weighted least-squares projection of scattered values onto continuous P1. + """Weighted least-squares projection of scattered values onto continuous + P1 (any simplex mesh) or P2 (triangles). Parameters ---------- mesh : Mesh A simplex mesh. + degree : int + 1 or 2, the degree of the continuous field; its rows are the rows of + any continuous mesh variable of that degree on the mesh (vertices, then + for degree 2 the edges). rtol : float Relative tolerance of the conjugate-gradient solve. """ instances = 0 - def __init__(self, mesh, rtol=1.0e-12): + def __init__(self, mesh, degree=1, rtol=1.0e-12): + if degree not in (1, 2): + raise NotImplementedError(f"ParticleL2Projector projects onto P1 or P2, not degree {degree}") + if degree == 2 and mesh.dim != 2: + raise NotImplementedError("ParticleL2Projector: degree 2 is for triangles (2-D) only") ParticleL2Projector.instances += 1 self.mesh = mesh + self.degree = int(degree) self.rtol = rtol - # the scalar layout the matrix and vectors live on; a P1 variable of any - # shape keeps its rows in the same vertex order (rows = section offsets) + # the scalar layout the matrix and vectors live on; a variable of this + # degree and any shape keeps its rows in the same point order (rows = + # section offsets over the vertices, then the edges) self._var = uw.discretisation.MeshVariable( - f"_pl2_{ParticleL2Projector.instances}", mesh, 1, degree=1, continuous=True) + f"_pl2_{ParticleL2Projector.instances}", mesh, 1, degree=self.degree, continuous=True) self._dm = None def _build(self): @@ -81,8 +103,20 @@ def _build(self): raise NotImplementedError("ParticleL2Projector needs a simplex mesh") cells.append(verts) cells = np.asarray(cells, dtype=np.int64).reshape(-1, d + 1) - self._rows = np.array([[sec.getOffset(int(p)) for p in row] for row in cells], - dtype=np.int32).reshape(-1, d + 1) + rows = [[sec.getOffset(int(p)) for p in row] for row in cells] + self._edge_pairs = None + if self.degree == 2: + # each cell's edges, as the local indices of the two vertices they join + e0, e1 = dm.getDepthStratum(1) + pairs = [] + for c, verts in zip(range(c0, c1), cells): + edges = [p for p in dm.getTransitiveClosure(c)[0] if e0 <= p < e1] + local = {int(v): k for k, v in enumerate(verts)} + rows[c - c0].extend(sec.getOffset(int(e)) for e in edges) + pairs.append([[local[int(q)] for q in dm.getCone(int(e))] for e in edges]) + self._edge_pairs = np.asarray(pairs, dtype=np.int64).reshape(-1, 3, 2) + self._rows = np.asarray(rows, dtype=np.int32).reshape(len(cells), -1) + self._nb = self._rows.shape[1] Xv = np.array([[coords[csec.getOffset(int(p)) // mesh.cdim, :d] for p in row] for row in cells]) self._Xv = Xv self._x0 = Xv[:, 0, :] if cells.size else np.zeros((0, d)) @@ -91,12 +125,27 @@ def _build(self): self._Tinv = np.linalg.inv(T) if cells.size else T measure = np.abs(np.linalg.det(T)) / (1.0 if d == 1 else 2.0 if d == 2 else 6.0) if cells.size else np.zeros(0) self.cell_measure = measure - # element matrices: consistent mass and stiffness of the linear simplex + # element matrices: consistent mass and stiffness, closed form for P1, + # by quadrature (exact) for P2 nv = d + 1 - base = (np.ones((nv, nv)) + np.eye(nv)) / ((d + 1) * (d + 2)) - self._Me = measure[:, None, None] * base[None, :, :] - G = np.concatenate([-self._Tinv.sum(axis=1, keepdims=True), self._Tinv], axis=1) # grad lambda, (nc, nv, d) - self._Ke = measure[:, None, None] * np.einsum("cid,cjd->cij", G, G) + self._G = np.concatenate([-self._Tinv.sum(axis=1, keepdims=True), self._Tinv], axis=1) \ + if cells.size else np.zeros((0, nv, d)) # grad lambda, (nc, nv, d) + if self.degree == 1: + base = (np.ones((nv, nv)) + np.eye(nv)) / ((d + 1) * (d + 2)) + self._Me = measure[:, None, None] * base[None, :, :] + self._Ke = measure[:, None, None] * np.einsum("cid,cjd->cij", self._G, self._G) + else: + lam_q, w_q = _TRI_RULE + ncell = cells.shape[0] + Me = np.zeros((ncell, self._nb, self._nb)) + Ke = np.zeros((ncell, self._nb, self._nb)) + for lam, wq in zip(lam_q, w_q): + L = np.broadcast_to(lam, (ncell, nv)) + phi = self._basis_from_lambda(L, np.arange(ncell)) # (nc, nb) + dphi = self._basis_gradient(L, np.arange(ncell)) # (nc, nb, d) + Me += wq * measure[:, None, None] * phi[:, :, None] * phi[:, None, :] + Ke += wq * measure[:, None, None] * np.einsum("cid,cjd->cij", dphi, dphi) + self._Me, self._Ke = Me, Ke self._A = self._sub.createMatrix() self._A.setOption(PETSc.Mat.Option.NEW_NONZERO_ALLOCATION_ERR, False) self._ksp = PETSc.KSP().create(comm=dm.comm) @@ -118,6 +167,32 @@ def barycentric(self, X, cell): lam = np.einsum("nij,nj->ni", self._Tinv[cell], X[:, :d] - self._x0[cell]) return np.concatenate([1.0 - lam.sum(axis=1, keepdims=True), lam], axis=1) + def _basis_from_lambda(self, lam, cell): + """The basis functions at points with barycentric coordinates ``lam`` + (n, d+1) in cells ``cell``: (n, nb).""" + if self.degree == 1: + return lam + pairs = self._edge_pairs[cell] # (n, 3, 2) + vertex = lam * (2.0 * lam - 1.0) + edge = 4.0 * lam[np.arange(len(cell))[:, None], pairs[:, :, 0]] * lam[np.arange(len(cell))[:, None], pairs[:, :, 1]] + return np.concatenate([vertex, edge], axis=1) + + def _basis_gradient(self, lam, cell): + """Gradients of the basis functions in physical coordinates: (n, nb, d).""" + G = self._G[cell] # (n, d+1, d) + if self.degree == 1: + return G + pairs = self._edge_pairs[cell] + n = np.arange(len(cell))[:, None] + vertex = (4.0 * lam - 1.0)[:, :, None] * G + la, lb = lam[n, pairs[:, :, 0]], lam[n, pairs[:, :, 1]] + edge = 4.0 * (la[:, :, None] * G[n, pairs[:, :, 1]] + lb[:, :, None] * G[n, pairs[:, :, 0]]) + return np.concatenate([vertex, edge], axis=1) + + def basis(self, X, cell): + """The basis functions at points ``X`` in cells ``cell``: (n, nb).""" + return self._basis_from_lambda(self.barycentric(np.asarray(X, dtype=float), cell), cell) + def project(self, X, values, weights, cell, old=None, eps=1.0e-8, alpha=None): """The projected field at the local rows, shape (n_local_rows, ncomponents). @@ -134,12 +209,12 @@ def project(self, X, values, weights, cell, old=None, eps=1.0e-8, alpha=None): k = values.shape[1] if values.ndim == 2 else (np.asarray(old).shape[1] if old is not None else 1) values = values.reshape(len(X), k) ncell = self._rows.shape[0] - lam = self.barycentric(np.asarray(X, dtype=float), cell) + phi = self.basis(X, cell) if len(X) else np.zeros((0, self._nb)) Me = np.zeros_like(self._Me) - Re = np.zeros((ncell, self._rows.shape[1], k)) + Re = np.zeros((ncell, self._nb, k)) w = np.asarray(weights, dtype=float) - np.add.at(Me, cell, w[:, None, None] * lam[:, :, None] * lam[:, None, :]) - np.add.at(Re, cell, w[:, None, None] * lam[:, :, None] * values[:, None, :]) + np.add.at(Me, cell, w[:, None, None] * phi[:, :, None] * phi[:, None, :]) + np.add.at(Re, cell, w[:, None, None] * phi[:, :, None] * values[:, None, :]) if eps > 0.0: Me = Me + eps * self._Me if old is not None: diff --git a/tests/test_1067_particle_projection.py b/tests/test_1067_particle_projection.py index aaba867d7..ec4a1a234 100644 --- a/tests/test_1067_particle_projection.py +++ b/tests/test_1067_particle_projection.py @@ -80,12 +80,44 @@ def test_the_forward_nodes_global_projection_reproduces_a_linear_field_from_its_ assert history._fit_overshoot < 1.0e-6 -def test_the_forward_nodes_global_projection_needs_a_linear_store(): +def test_a_quadratic_field_sampled_anywhere_in_the_cells_is_projected_exactly_at_degree_two(): + mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.1, qdegree=3) + pj = ParticleL2Projector(mesh, degree=2) + pj._build() + ncell = pj._rows.shape[0] + rng = np.random.default_rng(11 + uw.mpi.rank) + lam = rng.dirichlet(np.ones(3), size=(ncell, 9)) + X = np.einsum("cpi,cid->cpd", lam, pj._Xv).reshape(-1, 2) + cell = np.repeat(np.arange(ncell), 9) + w = np.repeat(pj.cell_measure / 9, 9) + + def f(P): + return np.c_[1.0 + 2.0 * P[:, 0] - 3.0 * P[:, 1] + P[:, 0] ** 2 - 0.5 * P[:, 0] * P[:, 1], + P[:, 1] ** 2 - P[:, 0]] + + u = pj.project(X, f(X), w, cell, old=np.zeros((pj.n_local_rows, 2)), eps=1.0e-12) + assert np.abs(u - f(np.asarray(pj._var.coords))).max() < 1.0e-8 + # the element mass matrix integrates to the cell measure + assert abs(pj._Me.sum() - pj.cell_measure.sum()) < 1.0e-12 + + +def test_the_forward_nodes_global_projection_reproduces_a_quadratic_velocity_from_its_launch_set(): + mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.2, qdegree=3) + v = uw.discretisation.MeshVariable("U_fwn_q2", mesh, 2, degree=2) + P = np.asarray(v.coords) + v.data[:, 0] = 1.0 + P[:, 0] ** 2 - 0.5 * P[:, 0] * P[:, 1] + v.data[:, 1] = P[:, 1] ** 2 - P[:, 0] + history = uw.systems.ddt.ForwardNodesSemiLagrangian(mesh, v.sym, sympy.Matrix([[0.0, 0.0]]), uw.VarType.VECTOR, + degree=2, reconstruction="global") + history.update_pre_solve(0.1) + assert np.abs(np.asarray(history.psi_star[0].data) - np.asarray(v.data)).max() < 1.0e-6 + assert history._fit_overshoot < 1.0e-6 + + +def test_the_global_projection_refuses_a_cubic_store(): mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.25, qdegree=3) - v = uw.discretisation.MeshVariable("U_fwn_p2", mesh, 2, degree=2) - with pytest.raises(NotImplementedError, match="degree 2"): - uw.systems.ddt.ForwardNodesSemiLagrangian(mesh, v.sym, v.sym, uw.VarType.VECTOR, degree=2, - reconstruction="global") + with pytest.raises(NotImplementedError, match="degree 3"): + ParticleL2Projector(mesh, degree=3) @pytest.mark.parametrize("reconstruction", ["cell", "global"]) From e37ab3542f0e82228970f5914f09a5477de6777d Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sun, 4 Oct 2026 16:06:35 +1100 Subject: [PATCH 13/21] ParticleL2Projector: fill a cell's uncovered share from the previous field; a bubble penalty for the degree-2 fit Deficit fill (on by default): the share of a cell's measure that the arriving weights do not cover enters as finite-element mass with the previous field as its data, the mechanism the inflow cells already use. A cell the flow has emptied is then held by what it carried rather than by a 1e-8 pull and its neighbours. On the viscoelastic cylinder the P2 velocity history blew up at the end of the inflow ramp without this (reversed flow at the wall from edge dofs set by two arrivals) and passed it with it. Bubble penalty (degree 2, bubble_penalty, default 0): each edge dof's departure from the mean of its two vertices is penalised in units of the bubble's own mass. P1 content is untouched; the quadratic content is kept only where the arrivals support it. The data of a fully covered cell hold a bubble with an effective weight of about 0.04 in these units (the fit trades a cell's bubble against its neighbours' dofs), so 0.01 removes about a fifth of a resolved quadratic and 0.1 about three quarters. The cylinder's fully semi-Lagrangian run (both histories by the global projection) survived developed shedding with the penalty where it failed without it; the forward nodal history exposes it as bubble_penalty. Underworld development team with AI support from Claude Code Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo --- src/underworld3/systems/ddt.py | 14 +++++ .../utilities/particle_projection.py | 52 ++++++++++++++--- tests/test_1067_particle_projection.py | 57 +++++++++++++++++++ 3 files changed, 116 insertions(+), 7 deletions(-) diff --git a/src/underworld3/systems/ddt.py b/src/underworld3/systems/ddt.py index 06314aa07..fe3ab49ae 100644 --- a/src/underworld3/systems/ddt.py +++ b/src/underworld3/systems/ddt.py @@ -6099,6 +6099,20 @@ def reconstruction(self, value): rtol=_HISTORY_PROJECTION_TOLERANCE) self._reconstruction = value + @property + def bubble_penalty(self) -> float: + """Degree 2 with the global projection: the weight, as a fraction of the + cell measure, of the prior that each cell's quadratic content is zero + (see :attr:`ParticleL2Projector.bubble_penalty`). Zero is the plain + least-squares fit.""" + return self._global_projector.bubble_penalty if self._global_projector is not None else 0.0 + + @bubble_penalty.setter + def bubble_penalty(self, value): + if self._global_projector is None: + raise ValueError("bubble_penalty applies to reconstruction='global'") + self._global_projector.bubble_penalty = float(value) + def _weights_of_launch(self): """Each launch point's share of the domain: the lattice points share their cell's measure, a node the measure of a cell that contains it; the diff --git a/src/underworld3/utilities/particle_projection.py b/src/underworld3/utilities/particle_projection.py index 19502e317..a567d3b55 100644 --- a/src/underworld3/utilities/particle_projection.py +++ b/src/underworld3/utilities/particle_projection.py @@ -82,6 +82,16 @@ def __init__(self, mesh, degree=1, rtol=1.0e-12): self._var = uw.discretisation.MeshVariable( f"_pl2_{ParticleL2Projector.instances}", mesh, 1, degree=self.degree, continuous=True) self._dm = None + #: Degree 2 only: the quadratic content of each cell, measured as each + #: edge dof's departure from the mean of its two vertices (the bubble), + #: is penalised at this fraction of the weight the data of a fully + #: covered cell give its bubbles (the bubble's own mass). The P1 part of + #: the field is untouched; the bubble is kept only where the arrivals + #: support it against a prior of that weight. An edge dof belongs to two + #: cells and is set by their quadratic content alone, so a cell the flow + #: has thinned leaves it to a few arrivals; the P1 vertex dofs, shared by + #: a whole patch, do not have this exposure. + self.bubble_penalty = 0.0 def _build(self): """The cell-to-row map, the element matrices and the solver, for the mesh @@ -146,6 +156,19 @@ def _build(self): Me += wq * measure[:, None, None] * phi[:, :, None] * phi[:, None, :] Ke += wq * measure[:, None, None] * np.einsum("cid,cjd->cij", dphi, dphi) self._Me, self._Ke = Me, Ke + # the bubble operator: sum over the cell's edges of b b^T with + # b = e_edge - (e_a + e_b) / 2, scaled by the cell measure + B = np.zeros((ncell, self._nb, self._nb)) + for k in range(3): + b = np.zeros((ncell, self._nb)) + b[:, nv + k] = 1.0 + np.put_along_axis(b, self._edge_pairs[:, k, 0:1], -0.5, axis=1) + np.put_along_axis(b, self._edge_pairs[:, k, 1:2], -0.5, axis=1) + B += b[:, :, None] * b[:, None, :] + # scaled by the L2 mass of one edge bubble, int (4 lam_a lam_b)^2 = + # (8/45) |cell|: bubble_penalty = 1 then weighs the prior as much as + # the data of a fully covered cell weigh its quadratic content + self._Bub = (8.0 / 45.0) * measure[:, None, None] * B self._A = self._sub.createMatrix() self._A.setOption(PETSc.Mat.Option.NEW_NONZERO_ALLOCATION_ERR, False) self._ksp = PETSc.KSP().create(comm=dm.comm) @@ -193,14 +216,23 @@ def basis(self, X, cell): """The basis functions at points ``X`` in cells ``cell``: (n, nb).""" return self._basis_from_lambda(self.barycentric(np.asarray(X, dtype=float), cell), cell) - def project(self, X, values, weights, cell, old=None, eps=1.0e-8, alpha=None): + def project(self, X, values, weights, cell, old=None, eps=1.0e-8, alpha=None, fill_deficit=True): """The projected field at the local rows, shape (n_local_rows, ncomponents). ``X`` (n, d), ``values`` (n, k), ``weights`` (n,) and ``cell`` (n,), the owning local cell of each point; ``old`` (n_local_rows, k), the previous - field, which unreached nodes keep; ``eps`` scales the pull towards it - relative to the finite-element mass matrix; ``alpha`` (ncell,) the - gradient penalty per cell.""" + field; ``eps`` scales a weak pull towards it relative to the + finite-element mass matrix (what keeps a row no point reaches at all); + ``alpha`` (ncell,) the gradient penalty per cell. + + With ``fill_deficit`` (and ``old``), the share of each cell's measure + that the arriving weights do not cover is supplied by the previous + field: that share enters as its finite-element mass with ``old`` as + the data. A cell the flow has emptied is then determined by what it + held, at full weight, rather than left to its neighbours and a + 1e-8 pull; a fully covered cell is unchanged. The weights are the + points' shares of the domain, so the sum over a cell measures how + much of it was reached.""" if self._dm is not self.mesh.dm: self._build() values = np.asarray(values, dtype=float) @@ -215,12 +247,18 @@ def project(self, X, values, weights, cell, old=None, eps=1.0e-8, alpha=None): w = np.asarray(weights, dtype=float) np.add.at(Me, cell, w[:, None, None] * phi[:, :, None] * phi[:, None, :]) np.add.at(Re, cell, w[:, None, None] * phi[:, :, None] * values[:, None, :]) - if eps > 0.0: - Me = Me + eps * self._Me + pull = np.full(ncell, float(eps)) + if fill_deficit and old is not None: + received = np.bincount(cell, weights=w, minlength=ncell) + pull = pull + np.clip(1.0 - received / np.maximum(self.cell_measure, 1.0e-300), 0.0, 1.0) + if np.any(pull > 0.0): + Me = Me + pull[:, None, None] * self._Me if old is not None: - Re = Re + eps * np.einsum("cij,cjk->cik", self._Me, np.asarray(old)[self._rows]) + Re = Re + pull[:, None, None] * np.einsum("cij,cjk->cik", self._Me, np.asarray(old)[self._rows]) if alpha is not None: Me = Me + np.asarray(alpha, dtype=float).reshape(-1)[:, None, None] * self._Ke + if self.degree == 2 and self.bubble_penalty > 0.0: + Me = Me + float(self.bubble_penalty) * self._Bub A = self._A A.zeroEntries() for c in range(ncell): diff --git a/tests/test_1067_particle_projection.py b/tests/test_1067_particle_projection.py index ec4a1a234..56811306b 100644 --- a/tests/test_1067_particle_projection.py +++ b/tests/test_1067_particle_projection.py @@ -114,6 +114,63 @@ def test_the_forward_nodes_global_projection_reproduces_a_quadratic_velocity_fro assert history._fit_overshoot < 1.0e-6 +def test_the_bubble_penalty_acts_on_the_quadratic_content_only(): + """The penalty leaves a linear field exact at any beta. A quadratic field fully + sampled in every cell loses its curvature progressively: the least-squares fit + trades a cell's bubble against its neighbours' dofs, so the data hold a bubble + with an effective weight of only about 0.04 in units of the bubble's own mass, + and beta 0.01 removes about a fifth of a resolved quadratic, 0.1 about three + quarters (measured on this mesh: 4.5e-4 and 1.6e-3 of the 2.2e-3 that + removing it all costs).""" + mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.1, qdegree=3) + pj = ParticleL2Projector(mesh, degree=2) + pj._build() + ncell = pj._rows.shape[0] + rng = np.random.default_rng(5 + uw.mpi.rank) + lam = rng.dirichlet(np.ones(3), size=(ncell, 9)) + X = np.einsum("cpi,cid->cpd", lam, pj._Xv).reshape(-1, 2) + cell = np.repeat(np.arange(ncell), 9) + w = np.repeat(pj.cell_measure / 9, 9) + P = np.asarray(pj._var.coords) + linear, quadratic = lambda Q: 1.0 + 2.0 * Q[:, 0] - 3.0 * Q[:, 1], lambda Q: Q[:, 0] ** 2 - 0.5 * Q[:, 0] * Q[:, 1] + errs = {} + for beta in (0.0, 0.01, 0.1, 1000.0): + pj.bubble_penalty = beta + u = pj.project(X, np.c_[linear(X), quadratic(X)], w, cell, old=np.zeros((pj.n_local_rows, 2)), eps=1.0e-12) + assert np.abs(u[:, 0] - linear(P)).max() < 1.0e-8, beta + errs[beta] = np.abs(u[:, 1] - quadratic(P)).max() + assert errs[0.0] < 1.0e-8 + assert 0.15 * errs[1000.0] < errs[0.01] < 0.30 * errs[1000.0] + assert 0.60 * errs[1000.0] < errs[0.1] < 0.85 * errs[1000.0] + assert 1.5e-3 < errs[1000.0] < 3.0e-3 # the quadratic content of x^2 on h ~ 0.1 cells + + +def test_the_deficit_fill_holds_an_emptied_cell_to_the_previous_field(): + """Half the cells receive no points at all: their rows take the previous + field at full weight (not a 1e-8 pull), while the sampled half is fitted.""" + mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.1, qdegree=3) + pj = ParticleL2Projector(mesh, degree=1) + pj._build() + ncell = pj._rows.shape[0] + rng = np.random.default_rng(3) + lam = rng.dirichlet(np.ones(3), size=(ncell, 5)) + X = np.einsum("cpi,cid->cpd", lam, pj._Xv).reshape(-1, 2) + cell = np.repeat(np.arange(ncell), 5) + w = np.repeat(pj.cell_measure / 5, 5) + left = X[:, 0] < 0.5 # points only in the left half + old = np.full((pj.n_local_rows, 1), 3.0) + u = pj.project(X[left], np.full((left.sum(), 1), 1.0), w[left], cell[left], old=old, eps=1.0e-8) + P = np.asarray(pj._var.coords) + # the consistent mass couples neighbours, so the step from the data (1) to the + # previous field (3) is spread over about two cells and decays geometrically + # beyond: 0.48 one cell in, 1.6e-2 at x > 0.75, 1.2e-3 at x > 0.85 + assert np.abs(u[P[:, 0] > 0.9] - 3.0).max() < 2.0e-3 # unreached: the previous field + assert np.abs(u[P[:, 0] < 0.1] - 1.0).max() < 5.0e-3 # sampled: the data (the same tail, 2.9e-3 four cells in) + # with no points at all the previous field comes back exactly + u0 = pj.project(np.zeros((0, 2)), np.zeros((0, 1)), np.zeros(0), np.zeros(0, dtype=int), old=old, eps=1.0e-8) + assert np.abs(u0 - 3.0).max() < 1.0e-9 + + def test_the_global_projection_refuses_a_cubic_store(): mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.25, qdegree=3) with pytest.raises(NotImplementedError, match="degree 3"): From 2fa180aea7cca39067b41aa4f167c1865c6da3d3 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sun, 4 Oct 2026 19:53:13 +1100 Subject: [PATCH 14/21] Projection smoothing setter: request a rewire only when the value changes Every stress history sets the smoothing of its commit projection each step, and the setter requested a rewire of the pointwise functions unconditionally, so the C source of the committed flux was regenerated every step (5 s a step for the Oldroyd-B shear box, 22 s for FENE-P, whose expression is larger; the compiled module was then found in the cache and not rebuilt). With the rewire gated on a change of value the shear-box solve goes from 7.5 s to 1.2 s. The baselines of test_1059, 1067, 1102, 1103 and 1104 are unchanged. Underworld development team with AI support from Claude Code Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo --- src/underworld3/systems/solvers.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/underworld3/systems/solvers.py b/src/underworld3/systems/solvers.py index 8403b26a2..c5df87218 100644 --- a/src/underworld3/systems/solvers.py +++ b/src/underworld3/systems/solvers.py @@ -3574,9 +3574,15 @@ class _SmoothingLengthMixin: """ def _set_smoothing(self, value): - """Store the smoothing coefficient alpha (units length squared).""" - self._needs_function_rewire = True - self._smoothing = sympify(value) + """Store the smoothing coefficient alpha (units length squared). A + rewire of the pointwise functions is requested only when the value + changes: the histories set the smoothing of their commit projections + every step, and an unconditional rewire regenerated the C source of the + committed flux each time (seconds a step, more for a large expression).""" + value = sympify(value) + if getattr(self, "_smoothing", None) is None or value != self._smoothing: + self._needs_function_rewire = True + self._smoothing = value def _get_smoothing_length(self): r"""Return :math:`L = \sqrt{\alpha}` (unit-aware, see mixin docstring).""" From 904048d8719cb034fa4f1d88e230b34c1d5e9b4e Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sun, 4 Oct 2026 19:53:13 +1100 Subject: [PATCH 15/21] FENE-P relaxation law for the viscoelastic model; element and relaxation named as independent choices ViscoElasticPlasticFlowModel(element="maxwell"|"jeffreys", relaxation="linear"|"fene_p") with Parameters.extensibility (L^2). The three choices a viscoelastic model makes are independent and named by their mechanics: the spring-dashpot arrangement (Maxwell; Jeffreys, a dashpot in parallel = the solvent viscosity), the relaxation law (linear = Hookean; FENE-P = finitely extensible with the Peterlin closure) and the objective rate. UCM is maxwell + linear + upper-convected, Oldroyd-B jeffreys + linear + upper-convected, FENE-P jeffreys + fene_p + upper-convected; a Burgers body would add a second history. FENE-P takes its step on the conformation at the rate f(c*)/lambda with f = (L^2 - d)/(L^2 - tr c), f* read from the record before the step (explicit, first order) and carried as nodal fields so the matrix exponential of the record does not enter the compiled flux through the coefficients; the stress is G (f* c - I). The stretching source acts on G (c* - I), which is the carried stress only for a linear spring, and keeps its Oldroyd-B weight: the f* on the relaxation rate cancels against the f* on the stress for every source. Against the closed-form steady simple shear (f^2 (f - 1) = 2 Wi^2 / L^2) the scheme is first order in dt/lambda: errors of +0.0065 / +0.0095 in tau_xy / N1 at dt = 0.1 lambda halving at 0.05. With infinite extensibility it is Oldroyd-B to 1e-8. Underworld development team with AI support from Claude Code Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo --- src/underworld3/constitutive_models.py | 220 +++++++++++++++++++++++-- tests/test_1068_fene_p.py | 135 +++++++++++++++ 2 files changed, 340 insertions(+), 15 deletions(-) create mode 100644 tests/test_1068_fene_p.py diff --git a/src/underworld3/constitutive_models.py b/src/underworld3/constitutive_models.py index 4379fcfa9..bbb32968f 100644 --- a/src/underworld3/constitutive_models.py +++ b/src/underworld3/constitutive_models.py @@ -1691,7 +1691,8 @@ class ViscoElasticPlasticFlowModel(ViscousFlowModel): def __init__(self, unknowns, order=1, integrator: str = "bdf", material_name: str = None, objective_rate: str = "none", - stress_history: str = "stress", convected_step: str = None): + stress_history: str = "stress", convected_step: str = None, + element: str = "maxwell", relaxation: str = "linear"): """Construct a viscoelastic-plastic flow model. Parameters @@ -1748,6 +1749,36 @@ def __init__(self, unknowns, order=1, integrator: str = "bdf", stretching of the relaxation target is completed the same way). Default ``"linear"``, or ``"deformation"`` with the log-conformation history, which requires it. + element : {"maxwell", "jeffreys"}, default "maxwell" + The spring-dashpot arrangement, which fixes the linear response. + ``"maxwell"`` is the spring and dashpot in series (the upper- + convected Maxwell fluid with the upper-convected rate). + ``"jeffreys"`` adds a dashpot in parallel, ``Parameters.solvent_viscosity`` + (Oldroyd-B with the upper-convected rate; in the mantle the + parallel dashpot is the steady-state creep alongside the transient + Maxwell element). A Burgers body (Maxwell in series with a + Kelvin-Voigt pair) would carry a second history and is not yet + implemented. + relaxation : {"linear", "fene_p"}, default "linear" + The element's relaxation law. ``"linear"`` is a Hookean spring: + constant relaxation time :math:`\lambda = \eta/G`, unbounded + extension, so in an extensional flow with + :math:`\lambda\dot\epsilon > 1/2` the stress grows without + limit. ``"fene_p"`` (finitely extensible, Peterlin closure) + relaxes at :math:`f(c)/\lambda` with + :math:`f = (L^2 - d)/(L^2 - \mathrm{tr}\,c)`, :math:`L^2` the + extensibility (``Parameters.extensibility``, dimensionless, the + mean square extension at which the spring stiffens without + bound), and carries the stress :math:`G(f c - I)`: the + conformation's trace stays below :math:`L^2` and the stress + saturates. The step is taken on the conformation with + :math:`f` read from the carried conformation (explicit, first + order); it needs the log-conformation history, the exponential + integrator at order 1 and the upper-convected rate. The three + choices, element, relaxation and ``objective_rate``, are + independent: UCM is maxwell + linear + upper-convected, Oldroyd-B + jeffreys + linear + upper-convected, FENE-P jeffreys + fene_p + + upper-convected. """ if integrator not in ("bdf", "etd"): raise ValueError( @@ -1789,6 +1820,33 @@ def __init__(self, unknowns, order=1, integrator: str = "bdf", "logarithm and exponential; 3-D is not implemented") self._stress_history = stress_history self._convected_step = convected_step + if element not in ("maxwell", "jeffreys"): + raise ValueError(f"element must be 'maxwell' or 'jeffreys', got {element!r}") + if relaxation not in ("linear", "fene_p"): + raise ValueError(f"relaxation must be 'linear' or 'fene_p', got {relaxation!r}") + if relaxation == "fene_p" and not (stress_history == "log_conformation" and integrator == "etd" + and order == 1): + raise NotImplementedError("relaxation='fene_p' takes its step on the conformation with the " + "relaxation rate f(c)/lambda: it needs stress_history='log_conformation', " + "integrator='etd' and order=1") + self._element = element + self._relaxation = relaxation + # FENE-P: the step in relaxation times, dt f(c*)/lambda, is a field + # (f* varies in space); it is evaluated at the nodes once per step and + # read by the weak form as a plain P1 field, so the matrix exponential of + # the record does not enter the compiled flux (and its Jacobian) a + # second time through the relaxation coefficient + self._fene_x = self._fene_f = None + if relaxation == "fene_p": + self._fene_x = uw.discretisation.MeshVariable( + f"fene_x_{id(self)}", unknowns.u.mesh, 1, degree=1, continuous=True, + varsymbol=r"{x_{\mathrm{FENE}}}") + # the spring factor f(c*) itself, the same way: the flux reads + # G (f* c* - I) with f* a field rather than a function of the record + self._fene_f = uw.discretisation.MeshVariable( + f"fene_f_{id(self)}", unknowns.u.mesh, 1, degree=1, continuous=True, + varsymbol=r"{f_{\mathrm{FENE}}}") + self._fene_f.data[:, 0] = 1.0 # Store material_name before creating expressions (needed by create_unique_symbol) self._material_name = material_name @@ -1892,6 +1950,12 @@ class _Parameters(_ParameterBase, _ViscousParameterAlias): "Solvent viscosity in parallel with the Maxwell element (Oldroyd-B when non-zero)", units="Pa*s", ) + extensibility = api_tools.Parameter( + R"{L^2}", + lambda inner_self: sympy.oo, + "FENE-P extensibility: the mean square dumbbell extension at which the spring stiffens " + "without bound (relaxation='fene_p'); infinite is the Hookean, Oldroyd-B, dumbbell", + ) @property def dt_elastic(inner_self): @@ -2108,6 +2172,35 @@ def _update_bdf_coefficients(self): self._bdf_c2.sym = coeffs[2] self._bdf_c3.sym = coeffs[3] + def _relaxation_steps_sym(self): + r"""The step in relaxation times, :math:`x = \Delta t\,f(c^*)/\lambda` + (:math:`f = 1` for Oldroyd-B), as a sympy expression: for FENE-P the + nodal field :meth:`_refresh_fene_x` fills before each solve.""" + if self._relaxation == "fene_p": + return self._fene_x.sym[0] + lam = self.Parameters.shear_viscosity_0 / self.Parameters.shear_modulus + return self.Parameters.dt_elastic / lam + + def _spring_factor_sym(self): + r"""The spring factor of the step, :math:`f^*`, as the weak form reads + it: the nodal field for FENE-P, one for a linear spring.""" + if self._relaxation == "fene_p": + return self._fene_f.sym[0] + return sympy.Integer(1) + + def _refresh_fene_x(self): + r"""Evaluate :math:`\Delta t\,f(c^*)/\lambda` at the nodes of the FENE-P + step field from the carried conformation (explicit: the record as it + stands before the solve).""" + lam = self.Parameters.shear_viscosity_0 / self.Parameters.shear_modulus + f_sym = self._peterlin_sym(self._carried_conformation_sym(0)) + from underworld3.systems.ddt import _to_nondim_ndarray + coords = np.asarray(self._fene_f.coords) + f_vals = np.asarray(_to_nondim_ndarray(uw.function.evaluate(f_sym, coords))).reshape(-1) + self._fene_f.data[:, 0] = f_vals + x_vals = np.asarray(_to_nondim_ndarray(uw.function.evaluate(self.Parameters.dt_elastic / lam, coords))).reshape(-1) + self._fene_x.data[:, 0] = x_vals * f_vals + def _update_history_coefficients(self): """Pre-solve hook: refresh integrator coefficients. @@ -2142,6 +2235,11 @@ def _update_history_coefficients(self): except (TypeError, ValueError): dt_val = None self.Unknowns.DFDt.update_exp_coefficients(dt_val, tau_eff) + if self._relaxation == "fene_p": + # the relaxation rate is f(c*)/lambda, a field: alpha = exp(-dt f*/lambda) + # with f* read from the carried conformation (explicit, first order) + self._refresh_fene_x() + self.Unknowns.DFDt._exp_alpha.sym = sympy.exp(-self._relaxation_steps_sym()) if self._order == 1: # ETD-1 reduction: φ = α makes the (φ-α)·ε̇* term zero # AND turns (1-φ)·ε̇ into (1-α)·ε̇. @@ -2179,21 +2277,80 @@ def _check_order_supported(self, order): raise NotImplementedError("convected_step='deformation' and the log-conformation history " "are first order only") + def _peterlin_sym(self, c): + r"""The FENE-P spring factor :math:`f(c) = (L^2 - d)/(L^2 - \mathrm{tr}\,c)` + of a conformation ``c`` (a sympy matrix); one for the Hookean dumbbell.""" + if self._relaxation != "fene_p": + return sympy.Integer(1) + d = self.Unknowns.u.mesh.dim + L2 = self.Parameters.extensibility + return (L2 - d) / (L2 - sympy.Matrix(c).trace()) + + def _peterlin_np(self, c): + """:meth:`_peterlin_sym` on an array of conformations (n, d, d).""" + if self._relaxation != "fene_p": + return np.ones(c.shape[0]) + d = c.shape[-1] + L2 = float(self.Parameters.extensibility.sym) + return (L2 - d) / (L2 - np.trace(c, axis1=1, axis2=2)) + + def _carried_conformation_sym(self, level=0): + r"""The carried conformation :math:`c^* = e^{\psi^*}` of the + log-conformation history at ``level``.""" + return _expm_sym2(sympy.Matrix(self.Unknowns.DFDt.psi_star[level].sym)) + + def _carried_strain_sym(self, level=0): + r"""The carried elastic strain in stress units, :math:`G(c^* - I)`: what + the objective rate's source acts on. It is the carried stress itself for + a linear spring; for FENE-P, where :math:`\sigma^* = G(f c^* - I)`, it is + :math:`(\sigma^* + G I)/f^* - G I` (the convected derivative stretches + the conformation, not the stress, and in steady shear the difference is + the factor :math:`1/f` on the shear stress).""" + if self._stress_history == "stress" or self._relaxation != "fene_p": + return self._carried_stress_sym(level) + return (self._carried_conformation_sym(level) - sympy.eye(2)) * self.Parameters.shear_modulus + def _carried_stress_sym(self, level=0): r"""The carried stress :math:`\sigma^*` of history level ``level``: the - stored level, or :math:`G(e^{\psi^*} - I)` for the log-conformation - history (the modulus as its expression, so a read of it carries units).""" + stored level, or :math:`G(f(c^*)\,c^* - I)` with :math:`c^* = e^{\psi^*}` + for the log-conformation history (:math:`f = 1` for Oldroyd-B; the + modulus as its expression, so a read of it carries units).""" stored = self.Unknowns.DFDt.psi_star[level].sym if self._stress_history == "stress": return stored - return (_expm_sym2(sympy.Matrix(stored)) - sympy.eye(2)) * self.Parameters.shear_modulus + c = self._carried_conformation_sym(level) + f = self._fene_f.sym[0] if self._relaxation == "fene_p" else sympy.Integer(1) + return (f * c - sympy.eye(2)) * self.Parameters.shear_modulus def encode_history(self, stress): r"""What the history stores for a stress: the stress, or - :math:`\log(\sigma/G + I)` for the log-conformation history.""" + :math:`\log c` with :math:`f(c)\,c = \sigma/G + I` for the + log-conformation history. For FENE-P the trace of that relation, + :math:`f\,\mathrm{tr}\,c = s` with :math:`s = \mathrm{tr}\,\sigma/G + d`, + gives :math:`\mathrm{tr}\,c = s L^2 / (L^2 - d + s)` and hence :math:`f` + in closed form.""" if self._stress_history == "stress": return stress - return _logm_sym2(sympy.Matrix(stress) / self.Parameters.shear_modulus + sympy.eye(2)) + fc = sympy.Matrix(stress) / self.Parameters.shear_modulus + sympy.eye(2) + if self._relaxation == "fene_p": + # c = (sigma/G + I)/f with the spring factor of the step, the field + # f* read from the record before the solve: the same first-order lag + # the relaxation rate carries (the exact inverse, through the trace, + # is :meth:`_fene_exact_conformation`; inlined it repeats the whole + # stress expression inside its own trace and the compiled commit + # grows by an order of magnitude) + fc = fc / self._fene_f.sym[0] + return _logm_sym2(fc) + + def _fene_exact_conformation(self, stress): + r"""The conformation of a FENE-P stress, exactly: from the trace of + :math:`f\,c = \sigma/G + I`, :math:`s = \mathrm{tr}\,\sigma/G + d`, + :math:`\mathrm{tr}\,c = s L^2/(L^2 - d + s)` and hence :math:`f`.""" + d = self.Unknowns.u.mesh.dim + L2 = self.Parameters.extensibility + fc = sympy.Matrix(stress) / self.Parameters.shear_modulus + sympy.eye(d) + tr_c = fc.trace() * L2 / (L2 - d + fc.trace()) + return fc * (L2 - tr_c) / (L2 - d) # The following should have no setters @property @@ -2268,8 +2425,15 @@ def E_eff(self): (1 - phi) * E + (alpha / (2 * eta_raw)) * sigma_star + (phi - alpha) * edot_star - # objective rate: the flux gains alpha * dt * S(sigma*) - + alpha * self.Parameters.dt_elastic * self._objective_term(sigma_star) / (2 * eta_raw) + # objective rate: the flux gains alpha * dt * f* * S(G (c* - I)). The + # step is taken on the conformation at the rate f*/lambda and the + # stress is G f* c: the f* on the relaxation cancels against the f* + # on the stress for every source, so the source keeps its Oldroyd-B + # weight alpha dt (= (1 - alpha) lambda to first order) and not + # alpha dt / f*, which the rate alone would give (f* = 1 for a + # linear spring) + + alpha * self.Parameters.dt_elastic * self._spring_factor_sym() + * self._objective_term(self._carried_strain_sym(0)) / (2 * eta_raw) ) if self._convected_step == "deformation": # The relaxation target is stretched during the step too. The @@ -2281,10 +2445,12 @@ def E_eff(self): # integrator's alpha, which is clamped to one in the elastic limit. L = sympy.Matrix(self.Unknowns.u.sym).jacobian(self.Unknowns.u.mesh.X) lam = eta_raw / self.Parameters.shear_modulus - x = self.Parameters.dt_elastic / lam + x = self._relaxation_steps_sym() # dt f*/lambda (f* = 1 for Oldroyd-B) decay = sympy.exp(-x) b = lam * (1 - (1 + x) * decay) - E_eff = E_eff + (b ** 2 / (1 - decay)) * L * L.T / (2 * lam) + # on the conformation the coefficient is b_c^2/(1-a) with b_c = b/f*; + # the stress multiplies by G f*, leaving b^2/((1-a) f*) + E_eff = E_eff + (b ** 2 / ((1 - decay) * self._spring_factor_sym())) * L * L.T / (2 * lam) self._E_eff.sym = E_eff return self._E_eff @@ -2298,7 +2464,7 @@ def E_eff(self): # flux gains (eta/mu) S(sigma*) and E_eff gains S/(2 mu) with weight # ONE whatever the BDF order (the BDF weights belong to the time # derivative, not to the source; -c1 = 2 at order 2 would double it). - E += self._objective_term(self._carried_stress_sym(0)) / (2 * self.Parameters.shear_modulus) + E += self._objective_term(self._carried_strain_sym(0)) / (2 * self.Parameters.shear_modulus) self._E_eff.sym = E return self._E_eff @@ -2346,7 +2512,8 @@ def _carried_stress(self): uw.function.evaluate(self.Parameters.shear_modulus.sym, points))).reshape(-1) w, v = np.linalg.eigh(tau) c = v @ (np.exp(w)[:, :, None] * np.transpose(v, (0, 2, 1))) - tau = G[:, None, None] * (c - np.eye(tau.shape[-1])[None]) + f = self._peterlin_np(c) + tau = G[:, None, None] * (f[:, None, None] * c - np.eye(tau.shape[-1])[None]) return tau, points def max_elastic_timestep(self, safety: float = 0.3) -> float: @@ -2420,9 +2587,16 @@ def conformation_min_eigenvalue(self): tau, points = self._carried_stress() dim = tau.shape[-1] from underworld3.systems.ddt import _to_nondim_ndarray - G = np.asarray(_to_nondim_ndarray( - uw.function.evaluate(self.Parameters.shear_modulus.sym, points))).reshape(-1) - c = tau / G[:, None, None] + np.eye(dim)[None, :, :] + if self._stress_history == "log_conformation": + # the conformation itself, from the record (the stress of a FENE-P + # element is G (f c - I), not G (c - I)) + psi, _ = self.Unknowns.DFDt.carried_tensors() + w, v = np.linalg.eigh(psi) + c = v @ (np.exp(w)[:, :, None] * np.transpose(v, (0, 2, 1))) + else: + G = np.asarray(_to_nondim_ndarray( + uw.function.evaluate(self.Parameters.shear_modulus.sym, points))).reshape(-1) + c = tau / G[:, None, None] + np.eye(dim)[None, :, :] ev = np.linalg.eigvalsh(c) # ascending per point lo = ev[:, 0] n_local = lo.size @@ -2813,6 +2987,22 @@ def yield_softness(self, value): self._yield_softness_expr.sym = sympy.Float(self._yield_softness) self._reset() + @property + def element(self) -> str: + """The spring-dashpot arrangement as built: ``"jeffreys"`` whenever the + parallel dashpot (``Parameters.solvent_viscosity``) is non-zero, + ``"maxwell"`` otherwise, whatever was declared.""" + try: + parallel = float(self.Parameters.solvent_viscosity.sym) != 0.0 + except (TypeError, ValueError): + parallel = True + return "jeffreys" if parallel else "maxwell" + + @property + def relaxation(self) -> str: + """The element's relaxation law: ``"linear"`` or ``"fene_p"``.""" + return self._relaxation + @property def requires_stress_history(self): """VEP models always require stress history tracking.""" diff --git a/tests/test_1068_fene_p.py b/tests/test_1068_fene_p.py new file mode 100644 index 000000000..e3a7120c1 --- /dev/null +++ b/tests/test_1068_fene_p.py @@ -0,0 +1,135 @@ +"""FENE-P (relaxation='fene_p'): the Peterlin closure in the log-conformation +history, checked against the closed-form steady simple shear and the Oldroyd-B +limit of infinite extensibility.""" +import numpy as np +import pytest +import sympy +import underworld3 as uw + +pytestmark = [pytest.mark.level_2, pytest.mark.tier_b] + + +def steady_shear_fene_p(Wi, L2, G=1.0, d=2): + """Steady homogeneous shear of a FENE-P fluid in d dimensions: from + 0 = L c + c L^T - (f c - I)/lambda with L = [[0, gdot], [0, 0]]: c_yy = 1/f, + c_xy = Wi/f^2, c_xx = (1 + 2 Wi^2/f^2)/f, and f = (L^2 - d)/(L^2 - tr c) closes + to f^2 (f - 1) = 2 Wi^2 / L^2 (d = 2). Returns (tau_xy / (eta gdot), N1 / G).""" + roots = np.roots([1.0, -1.0, 0.0, -2.0 * Wi ** 2 / L2]) + f = float(max(r.real for r in roots if abs(r.imag) < 1e-12 and r.real > 0)) + return 1.0 / f, 2.0 * Wi ** 2 / f ** 2 + + +def shear_box(relaxation, L2, dt, steps, tag): + """The Maxwell shear box, eta = G = 1 (lambda = 1), wall speed 0.5 over a unit + gap (gdot = 1, Wi = 1), ETD-1 with the log-conformation store, run to a steady + state; returns the polymer stress (xy, N1) at the centre, by projection.""" + mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(-1.0, -0.5), maxCoords=(1.0, 0.5), cellSize=0.25, qdegree=3) + v = uw.discretisation.MeshVariable(f"U_{tag}", mesh, 2, degree=2) + p = uw.discretisation.MeshVariable(f"P_{tag}", mesh, 1, degree=1) + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + stokes.stress_transport = "backward_nodes" + stokes.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + stokes.Unknowns, order=1, integrator="etd", objective_rate="upper_convected", + stress_history="log_conformation", relaxation=relaxation, element="maxwell") + cm = stokes.constitutive_model + cm.Parameters.shear_viscosity_0 = 1.0 + cm.Parameters.shear_modulus = 1.0 + cm.Parameters.dt_elastic = dt + if L2 is not None: + cm.Parameters.extensibility = L2 + stokes.add_dirichlet_bc((0.5, 0.0), "Top") + stokes.add_dirichlet_bc((-0.5, 0.0), "Bottom") + stokes.add_dirichlet_bc((sympy.oo, 0.0), "Left") + stokes.add_dirichlet_bc((sympy.oo, 0.0), "Right") + stokes.tolerance = 1.0e-8 + for _ in range(steps): + stokes.solve(timestep=dt, zero_init_guess=False) + sigma = cm._carried_stress_sym(0) + point = np.array([[0.0, 0.0]]) + xy = float(np.asarray(uw.function.evaluate(sigma[0, 1], point)).reshape(-1)[0]) + n1 = float(np.asarray(uw.function.evaluate(sigma[0, 0] - sigma[1, 1], point)).reshape(-1)[0]) + return xy, n1 + + +def test_fene_p_steady_shear_converges_to_the_closed_form_at_first_order(): + """Wi = 1, L^2 = 10: f = 1.1510 (f^2 (f - 1) = 0.2), tau_xy = 0.8688 eta gdot, + N1 = 1.5097 G. The spring factor is read from the record before the step, so + the scheme's steady state is first order in dt/lambda: measured errors + +0.0065 / +0.0095 at dt = 0.1 and +0.0032 / +0.0052 at dt = 0.05 (an earlier + form of the step kept one 1/f too many on the stretching source and sat + 0.03 / 0.13 off at every dt).""" + xy_ref, n1_ref = steady_shear_fene_p(Wi=1.0, L2=10.0) + assert abs(xy_ref - 0.8688) < 1e-3 and abs(n1_ref - 1.5097) < 1e-3 + xy1, n11 = shear_box("fene_p", 10.0, 0.1, 80, "fene_a") + xy2, n12 = shear_box("fene_p", 10.0, 0.05, 160, "fene_b") + e1 = (abs(xy1 - xy_ref), abs(n11 - n1_ref)) + e2 = (abs(xy2 - xy_ref), abs(n12 - n1_ref)) + assert e1[0] < 0.010 and e1[1] < 0.015, (xy1, n11) + assert e2[0] < 0.6 * e1[0] and e2[1] < 0.6 * e1[1], (e1, e2) + # the linear spring at the same dt is the Oldroyd-B steady state, and the + # FENE-P reduction (13% in shear stress, 25% in N1) is far outside these errors + xy_ob, n1_ob = shear_box("linear", None, 0.1, 80, "ob_a") + assert abs(xy_ob - 1.0) < 0.03 and abs(n1_ob - 2.0) < 0.08, (xy_ob, n1_ob) + assert xy1 < 0.92 * xy_ob and n11 < 0.85 * n1_ob + + +def test_fene_p_with_infinite_extensibility_is_oldroyd_b(): + xy, n1 = shear_box("fene_p", 1.0e12, 0.1, 10, "fene_inf") + xy_ob, n1_ob = shear_box("linear", None, 0.1, 10, "ob_10") + assert abs(xy - xy_ob) < 1e-8 and abs(n1 - n1_ob) < 1e-8 + + +def test_the_element_reports_the_parallel_dashpot(): + mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.5, qdegree=3) + v = uw.discretisation.MeshVariable("U_elem", mesh, 2, degree=2) + p = uw.discretisation.MeshVariable("P_elem", mesh, 1, degree=1) + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + cm = uw.constitutive_models.ViscoElasticPlasticFlowModel(stokes.Unknowns, order=1, integrator="etd", + objective_rate="upper_convected", element="jeffreys") + assert cm.element == "maxwell" and cm.relaxation == "linear" # no parallel dashpot yet + cm.Parameters.solvent_viscosity = 0.5 + assert cm.element == "jeffreys" + with pytest.raises(ValueError, match="element"): + uw.constitutive_models.ViscoElasticPlasticFlowModel(stokes.Unknowns, element="burgers") + + +def test_fene_p_needs_the_log_conformation_etd_path(): + mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.5, qdegree=3) + v = uw.discretisation.MeshVariable("U_fene_bad", mesh, 2, degree=2) + p = uw.discretisation.MeshVariable("P_fene_bad", mesh, 1, degree=1) + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + with pytest.raises(NotImplementedError, match="fene_p"): + uw.constitutive_models.ViscoElasticPlasticFlowModel(stokes.Unknowns, order=1, integrator="bdf", + relaxation="fene_p") + + +def test_the_fene_p_encoding_inverts_the_decoding(): + """encode_history(sigma) then the decode gives sigma back, at a non-trivial stress.""" + mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.5, qdegree=3) + v = uw.discretisation.MeshVariable("U_fene_enc", mesh, 2, degree=2) + p = uw.discretisation.MeshVariable("P_fene_enc", mesh, 1, degree=1) + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + stokes.stress_transport = "backward_nodes" + cm = uw.constitutive_models.ViscoElasticPlasticFlowModel( + stokes.Unknowns, order=1, integrator="etd", objective_rate="upper_convected", + stress_history="log_conformation", relaxation="fene_p") + cm.Parameters.shear_modulus = 2.0 + cm.Parameters.extensibility = 10.0 + sigma = sympy.Matrix([[3.0, 0.7], [0.7, 0.4]]) + point = np.array([[0.5, 0.5]]) + from underworld3.constitutive_models import _expm_sym2 + + def at_point(m): + return np.array([[float(np.asarray(uw.function.evaluate(m[i, j], point)).reshape(-1)[0]) for j in range(2)] + for i in range(2)]) + + # the exact inverse: f c = sigma/G + I with f = (L^2 - 2)/(L^2 - tr c) + c_exact = cm._fene_exact_conformation(sigma) + f_exact = cm._peterlin_sym(c_exact) + assert np.abs(at_point(f_exact * c_exact) - (np.array(sigma, dtype=float) / 2.0 + np.eye(2))).max() < 1e-10 + # the history's encoding uses the spring factor of the step as a field: with + # that field holding the exact f, encode then decode gives sigma back + cm._fene_f.data[:, 0] = float(np.asarray(uw.function.evaluate(f_exact, point)).reshape(-1)[0]) + c = _expm_sym2(cm.encode_history(sigma)) + back = (cm._fene_f.sym[0] * c - sympy.eye(2)) * cm.Parameters.shear_modulus + assert np.abs(at_point(back) - np.array(sigma, dtype=float)).max() < 1e-8 From 59366e9687e3dd40e146eb9d4b13bc20863abe1c Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sun, 4 Oct 2026 21:28:22 +1100 Subject: [PATCH 16/21] Review (78e62a9a): the composed solvers off the Eulerian path, and the docs for the forward reconstructions and FENE-P AdvDiffusion: the diffusive flux reads a stored level without a derivative through its snapshot stand-in, as the Navier-Stokes solver does, so transport= 'backward_integration_points' runs at the default theta 0.5 (test_0066's 'cn' case is now a comparison, not a refusal); a nodal trace-back marks the field CARRY for a moving mesh, as the old solver did; the SUPG options are refused off the Eulerian path instead of being set on a manager that ignores them; a discontinuous field is refused for every transport; the V_fn setter reaches the characteristic trace; a theta change across 1.0 requests a rewire (the integration-point form drops the stored level at 1); estimate_dt defaults to the cell-crossing time for a semi-Lagrangian transport; `transport` is readable. NavierStokes: the extrapolation state (a_var, u_prev) exists on the Eulerian path only; the SUPG options are refused otherwise; solve(order=) is refused rather than dropped; `velocity_transport` is readable. The deprecation warning on the former names says what differs (defaults, the meaning of order, the form of estimate_dt). Baselines for AdvDiffusion with each transport on the rotating Gaussian (test_1102). Docs: the per-cell fit against the global projection, the bubble penalty, store smoothing on all three histories, the element / relaxation / objective-rate taxonomy and FENE-P. Underworld development team with AI support from Claude Code Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo --- docs/api/solvers.md | 6 +- docs/api/utilities.md | 7 ++ docs/developer/subsystems/stress-transport.md | 110 ++++++++++++++++-- src/underworld3/systems/__init__.py | 23 +++- .../systems/advection_diffusion_eulerian.py | 110 ++++++++++++++---- .../systems/navier_stokes_eulerian.py | 64 +++++++--- src/underworld3/systems/solvers.py | 4 +- tests/test_0066_integration_point_slcn.py | 11 +- ...st_1102_forward_nodes_rotating_gaussian.py | 48 ++++++++ 9 files changed, 320 insertions(+), 63 deletions(-) diff --git a/docs/api/solvers.md b/docs/api/solvers.md index 55027e56e..6b706c5b0 100644 --- a/docs/api/solvers.md +++ b/docs/api/solvers.md @@ -103,8 +103,10 @@ default `EulerianSUPG` manager it is the implicit Eulerian SUPG scheme. ### SNES_NavierStokes_Composed (`uw.systems.NavierStokes`) -Navier-Stokes composed from a DDt transport manager (Eulerian SUPG momentum -transport by default); the semi-Lagrangian class above is `uw.systems.NavierStokesSLCN`. +Navier-Stokes composed from a DDt transport manager: `velocity_transport="eulerian"` +(SUPG on the mesh, the default) or one of the semi-Lagrangian schemes, and for a +viscoelastic model `stress_transport` chooses the stress history the same way. The +former `uw.systems.NavierStokesSLCN` is deprecated and still returns the class above. ```{eval-rst} .. autoclass:: underworld3.systems.navier_stokes_eulerian.SNES_NavierStokes_Composed diff --git a/docs/api/utilities.md b/docs/api/utilities.md index ed57c6532..3299d9554 100644 --- a/docs/api/utilities.md +++ b/docs/api/utilities.md @@ -117,3 +117,10 @@ Array wrapper that triggers callbacks on modification. :members: :show-inheritance: ``` + +## Particle projection + +```{eval-rst} +.. automodule:: underworld3.utilities.particle_projection + :members: +``` diff --git a/docs/developer/subsystems/stress-transport.md b/docs/developer/subsystems/stress-transport.md index b72d0f881..90601065a 100644 --- a/docs/developer/subsystems/stress-transport.md +++ b/docs/developer/subsystems/stress-transport.md @@ -30,8 +30,8 @@ are accepted with a warning. |---|---|---|---|---| | `backward_nodes` (the default) | continuous P1 at the vertices | vertex trace-back, interpolation at the foot | any Courant number | excess stress in the first cells off a no-slip wall; on the confined cylinder that excess loses the conformation and the solve hangs | | `backward_integration_points` | continuous P1 store, sampled at the quadrature points | trace-back of every quadrature point | Courant near one, or below one with store smoothing | a cell-scale mode of the stress that grows below Courant one when the solvent viscosity is small | -| `forward_integration_points` | discontinuous P1 per cell, fitted from the arrivals | fixed launch set of interior points (the integration points), one forward trajectory a step; the flux is read back at the launch points through a continuous P1 projection; an inflow cell's uncovered share is filled with the inflow value | the cylinder walls at dt 0.04; below Courant one with `flux_smoothing` at c = 0.023 (Waters-King 1/16, dt 0.0125: 0.9543 at t 1 and 0.5185 at t 6.5, against nodal 0.9622 and 0.5171) | the same cell-scale mode as the integration-point history without that smoothing (diverges at t 2.4 there); first order only; does not cross a periodic seam or follow a moving mesh | -| `forward_nodes` | continuous, the history's degree, at its nodes | the stress projected onto that store, launched from its nodes and from a lattice inside each element, one forward trajectory a step; a per-cell fit at the history's degree read back at the nodes | measured on transport alone (rotating diffusing Gaussian, P2: 1.78e-2 against 2.52e-2 for `backward_nodes` over half a turn) | not yet measured on a stress benchmark; first order only; does not follow a moving mesh | +| `forward_integration_points` | discontinuous P1 per cell | fixed launch set of interior points (the integration points), one forward trajectory a step; the arrivals are reconstructed per cell (`reconstruction="cell"`, a weighted linear fit) or by one global projection (`"global"`, see below); the flux is read back at the launch points through a continuous P1 projection; an inflow cell's uncovered share is filled with the inflow value | the cylinder walls at dt 0.04; below Courant one with `store_smoothing` (Waters-King 1/16, dt 0.0125, c = 0.023: 0.9543 at t 1 and 0.5185 at t 6.5, against nodal 0.9622 and 0.5171); the vortex-shedding cylinder at Re 200, Wi 0.5 with the global projection and no smoothing | with the per-cell fit, the same cell-scale mode as the integration-point history without smoothing, and an instability of the fit where the flow empties a cell (the rear stagnation point of the cylinder); first order only; does not cross a periodic seam or follow a moving mesh | +| `forward_nodes` | continuous, the history's degree, at its nodes | the stress projected onto that store, launched from its nodes and from a lattice inside each element, one forward trajectory a step; the arrivals reconstructed per cell at the history's degree and projected, or by one global projection (`reconstruction="global"`, P1 or P2) | measured on transport alone (rotating diffusing Gaussian, P2: 1.78e-2 against 2.52e-2 for `backward_nodes` over half a turn); the vortex-shedding cylinder to t = 8 with both reconstructions | a P2 velocity carried this way sheds at St 0.26 against 0.30 for the SUPG momentum transport (not yet understood; a resolution question); first order only; does not follow a moving mesh | | `lagrangian` (particles) | a swarm the solver owns and advects, one value per particle, read through a discontinuous cells proxy | the material points themselves: the constitutive flux is evaluated at the particles each step and never projected back to the mesh; a particle that entered through an inflow takes the inflow value | any Courant number; no numerical diffusion of the history | the cost and bookkeeping of a swarm, and a proxy that needs its cells kept populated (population control refills them); the conformation check does not read a per-point tensor from it | | `eulerian` (SUPG grid) | continuous P1 | assembled transport equation with streamline upwinding | with DEVSS | without DEVSS the velocity block loses its preconditioner as the stress grows | @@ -92,8 +92,8 @@ make that so, and a new history has to respect them: neighbourhood (#682, 1.6% of a level set's volume at np 8). The same holds for the value histories of advection-diffusion -(`AdvDiffusionSLCN(transport=...)`) and for the Navier-Stokes velocity history -(`NavierStokesSLCN(velocity_transport=...)`; the forward integration-point fit +(`AdvDiffusion(transport=...)`) and for the Navier-Stokes velocity history +(`NavierStokes(velocity_transport=...)`; the forward integration-point fit is linear, so it refuses a P2 velocity). ## With inertia @@ -127,7 +127,10 @@ gradient itself (the objective rate's) and a yielding viscosity need second derivatives and remain outside the residual. The former `NavierStokesSLCN` and `AdvDiffusionSLCN` still work, with a -warning; `AdvDiffusion` takes `transport=` in the same way. +deprecation warning that names the replacement and what differs (the +defaults, the meaning of `order`, the form of `estimate_dt`); `AdvDiffusion` +takes `transport=` in the same way, and the SUPG options of either solver are +refused off the Eulerian path. ## The timestep is set by the wall strain rate, not the far-field Courant number @@ -232,6 +235,42 @@ a quarter more error in the stress near a singular corner than storing the stres `SNES_NavierStokes` (the Navier-Stokes solver that reads its history directly as a flux) and the multi-material model refuse the log-conformation history. +## The relaxation law: a linear spring or FENE-P + +The model names its three independent choices by their mechanics: +`element` (the spring-dashpot arrangement: `"maxwell"`, or `"jeffreys"` with the +parallel dashpot `solvent_viscosity`), `relaxation` (`"linear"`, the Hookean +spring with a constant relaxation time, or `"fene_p"`) and `objective_rate`. +UCM is maxwell + linear + upper-convected, Oldroyd-B jeffreys + linear + +upper-convected, FENE-P jeffreys + fene_p + upper-convected. + +A linear spring extends without bound: in an extensional flow with +$\lambda\dot\epsilon > 1/2$ the stress grows without limit, which on the +cylinder wake happens between Wi 1 and 2 at Re 200, where the stress fills the +wake with structure at the mesh scale on every mesh (it is the model, not the +scheme). FENE-P relaxes at $f(c)/\lambda$ with +$f = (L^2 - d)/(L^2 - \mathrm{tr}\,c)$ and carries the stress $G(f c - I)$, so +the trace of the conformation stays below the extensibility +`Parameters.extensibility` ($L^2$) and the stress saturates. The step is taken +on the conformation with $f^*$ read from the record before the step (explicit, +first order; carried as a nodal field so the matrix exponential of the record +does not enter the compiled flux through the coefficients). Two things in the +step differ from the linear spring: the stretching source acts on $G(c^* - I)$, +which is the carried stress only for a linear spring; and the $f^*$ on the +relaxation rate cancels against the $f^*$ on the stress for every source, so +the sources keep their Oldroyd-B weights. Against the closed-form steady +simple shear ($f^2 (f - 1) = 2\,\mathrm{Wi}^2/L^2$) the scheme is first order +in $\Delta t/\lambda$; with infinite extensibility it is Oldroyd-B to 1e-8. +It needs the log-conformation history, the exponential integrator at order 1 +and the upper-convected rate. + +```python +cm = uw.constitutive_models.ViscoElasticPlasticFlowModel( + stokes.Unknowns, order=1, integrator="etd", objective_rate="upper_convected", + stress_history="log_conformation", element="jeffreys", relaxation="fene_p") +cm.Parameters.extensibility = 100.0 +``` + ## The recommended configuration Integration-point history, the step set by the wall strain rate @@ -240,16 +279,65 @@ is a small fraction of the total, DEVSS off. That combination is characterised o Waters and King (regular and irregular meshes) and on the confined cylinder, admissible to Wi 0.6 and mildly indefinite at Wi 0.8. The coefficient 0.023 is the least that holds the mode on a regular mesh; 0.07 holds it on an irregular -one as well and costs half a percent, so it is the recommended value. The forward flavour is the same scheme with a per-cell fit and interior +one as well and costs half a percent, so it is the recommended value. The forward flavour is the same scheme with interior launch points, measured at 17 s a step against 28 on the cylinder, parallel by -handing the arrivals that cross a seam to the rank that owns them; it needs its read-back smoothing (`DFDt.flux_smoothing = 0.023 * mesh.cell_size()**2`). Neither transports its memory without a cell-scale mode below Courant -one on a Maxwell element: a version that did not ring turned out not to be -transporting the memory at all. +handing the arrivals that cross a seam to the rank that owns them; with the +per-cell fit it needs the same store smoothing, with the global projection +(below) none. The per-cell reconstructions do not transport their memory without +a cell-scale mode below Courant one on a Maxwell element: a version that did not +ring turned out not to be transporting the memory at all. + +## Reconstructing the forward histories: a per-cell fit or one global projection + +A forward history knows its field at the points that arrived in each cell and +has to turn them back into a field the weak form can read. The per-cell fit +(`reconstruction="cell"`) fits a polynomial to the arrivals of each cell on its +own. It is local and cheap, and it extrapolates: a cell's vertices lie outside +its interior arrivals, so the fit always reaches beyond its data, by about a +quarter of the carried range on the cylinder, and in a cell the flow has emptied +(the rear stagnation point) a determined but ill-conditioned fit can put a value +there that nothing carried. On the shedding cylinder that excursion grew from the +background to three times the carried range in three steps and the solve failed; +store smoothing at c = 0.07 held it, at the cost of a third of the drag. + +The global projection (`reconstruction="global"`, +:class:`~underworld3.utilities.particle_projection.ParticleL2Projector`) is +the ordinary L2 projection with the arrivals as its quadrature points: one +weighted least-squares solve for the whole continuous field, each launch point +weighing its share of the cell it left. A node is set by every arrival in the +patch of cells around it, so it is interpolated rather than extrapolated; the +share of a cell's measure that no arrival covers enters as finite-element mass +with the previous field as its data, so a cell the flow has emptied is held by +what it carried; and the system is summed across partition seams, so the answer +does not depend on the partition (drag and lift at np 4 against serial to 5e-5 +over 24 steps of the cylinder, the momentum solve's own tolerance included). On +the cylinder at Re 200, Wi 0.5 it runs to t = 8 with no smoothing and no limiter, +the field's excursion beyond the carried range steady at 2-5%, and lands within +8% in drag and 12% in lift of the Eulerian stress history at the same Strouhal +number, where the smoothed per-cell fit was 35% low in drag. + +At degree 2 (the forward nodal history of a P2 velocity) the projection needs +regularising: an edge dof belongs to two cells and is set by their quadratic +content alone, so a thinned cell leaves it to a few arrivals, and the run failed +in developed shedding. `bubble_penalty` penalises each edge dof's departure from +the mean of its two vertices, in units of the bubble's own mass; the P1 part of +the field is untouched. The data of a fully covered cell hold a bubble only +weakly (the fit trades a cell's bubble against its neighbours), so a value of +0.01 removes about a fifth of a resolved quadratic and 0.1 about three quarters; +the cylinder ran to t = 8 at the equivalent of 0.56. + +```python +stokes.DFDt.reconstruction = "global" # either forward flavour +ns.DuDt.reconstruction = "global" # the forward nodal velocity history (P2) +ns.DuDt.bubble_penalty = 0.1 +``` -## Store smoothing for the integration-point history below Courant one +## Store smoothing below Courant one The store cycle of the integration-point flavour, sample at the points then -project, is a consistent-mass Galerkin transport of the carried stress. It has no +project, is a consistent-mass Galerkin transport of the carried stress (the +forward flavours have the same cycle and take the same `store_smoothing`; with +the global projection it is unnecessary). It has no dissipation at the cell scale, so below Courant one a cell-scale mode grows from round-off at a rate $\gamma$ set by the elastic feedback: about 2.4 per unit time on the Maxwell Waters-King start-up, 1.8 with a solvent fraction of 0.2, and not diff --git a/src/underworld3/systems/__init__.py b/src/underworld3/systems/__init__.py index fa58d99da..6be879c55 100644 --- a/src/underworld3/systems/__init__.py +++ b/src/underworld3/systems/__init__.py @@ -104,12 +104,23 @@ # Former names. One solver per equation, the transport chosen by argument: # NavierStokes(velocity_transport=...), AdvDiffusion(transport=...). _FORMER_NAMES = { + # former name: (the implementation it still returns, the replacement, what differs) "NavierStokesSLCN": ("SNES_NavierStokes", - "uw.systems.NavierStokes(..., velocity_transport='backward_nodes')"), + "uw.systems.NavierStokes(..., velocity_transport='backward_nodes')", + "defaults differ: order 1 (was 2), rho 1 (was 0), p_continuous True (was False), " + "no velocity-history smoothing (was 1e-4); estimate_dt returns one number " + "(was a (diffusive, advective) pair); the order is fixed at construction " + "(solve(order=) is refused) and flux_order is not an argument"), "NavierStokesSwarm": ("SNES_NavierStokes", - "uw.systems.NavierStokes(..., velocity_transport='backward_nodes')"), + "uw.systems.NavierStokes(..., velocity_transport='backward_nodes')", + "as for NavierStokesSLCN; a particle velocity history is velocity_transport='lagrangian'"), "AdvDiffusionSLCN": ("SNES_AdvectionDiffusion", - "uw.systems.AdvDiffusion(..., transport='backward_nodes')"), + "uw.systems.AdvDiffusion(..., transport='backward_nodes')", + "order is the order of the value history (BDF2 at order 2, theta 1; the old " + "solver kept BDF1 and raised the flux rule's order); the stored-level flux is " + "rebuilt from the carried field, not traced; estimate_dt() defaults to the " + "cell-crossing time for a semi-Lagrangian transport; the SUPG options are " + "refused off the Eulerian path"), } @@ -117,7 +128,9 @@ def __getattr__(name): if name in _FORMER_NAMES: import warnings from . import solvers - implementation, instead = _FORMER_NAMES[name] - warnings.warn(f"uw.systems.{name} is {instead}", FutureWarning, stacklevel=2) + implementation, instead, differs = _FORMER_NAMES[name] + warnings.warn(f"uw.systems.{name} is deprecated and still returns the old implementation " + f"(solvers.{implementation}); use {instead}. Note: {differs}.", + FutureWarning, stacklevel=2) return getattr(solvers, implementation) raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/underworld3/systems/advection_diffusion_eulerian.py b/src/underworld3/systems/advection_diffusion_eulerian.py index 3a39273c0..0a05f2138 100644 --- a/src/underworld3/systems/advection_diffusion_eulerian.py +++ b/src/underworld3/systems/advection_diffusion_eulerian.py @@ -75,21 +75,31 @@ class SNES_AdvectionDiffusion_Composed(SNES_Scalar): \frac{\partial \phi}{\partial t} + \mathbf{u}\cdot\nabla\phi - \nabla\cdot(\kappa\nabla\phi) = f - A drop-in replacement for :class:`~underworld3.systems.solvers.SNES_AdvectionDiffusion` - (``uw.systems.AdvDiffusionSLCN``): the constructor, ``order``, ``theta``, - ``f``, ``V_fn``, ``constitutive_model``, ``delta_t``, ``estimate_dt`` and - ``solve`` all keep the semi-Lagrangian solver's meaning, so a script changes - the class name and nothing else:: - - adv = uw.systems.AdvDiffusion(mesh, T, v.sym, order=1) # was AdvDiffusionSLCN + ``transport`` chooses how the history is carried: ``"eulerian"`` (the + default, above) or one of the semi-Lagrangian schemes + (``"backward_nodes"``, ``"backward_integration_points"``, + ``"forward_integration_points"``, ``"forward_nodes"``), in which case the + history manager carries the field along ``V_fn`` and the weak form has + neither an assembled advection nor a stabilisation:: + + adv = uw.systems.AdvDiffusion(mesh, T, v.sym, order=1, transport="backward_nodes") adv.constitutive_model = uw.constitutive_models.DiffusionModel adv.constitutive_model.Parameters.diffusivity = 1.0e-3 adv.add_dirichlet_bc(0.0, "Left") adv.solve(timestep=dt) - The arguments that only make sense for a trace-back - (``restore_points_func``, ``monotone_mode``, ``old_frame_traceback``, - ``DFDt``) are accepted and ignored with a warning. + Against the former ``AdvDiffusionSLCN`` (:class:`~underworld3.systems.solvers.SNES_AdvectionDiffusion`) + with the same history: ``order`` here is the order of the value history + (BDF2 at order 2, theta = 1) where the old solver kept BDF1 and raised the + flux rule's order; the stored-level flux is rebuilt from the carried field + with the current diffusivity (an integration-point store through its + continuous snapshot) rather than traced as a flux history; ``estimate_dt`` + defaults to the cell-crossing time for a semi-Lagrangian transport and to + the accuracy estimate for the Eulerian one. The SUPG options + (``peclet_weight``, ``supg_weight``, ``tau_weights``) belong to + ``transport="eulerian"`` and are refused otherwise; ``restore_points_func`` + and ``DFDt`` are accepted and ignored with a warning, and the forward + schemes warn and ignore ``monotone_mode`` and ``old_frame_traceback``. **Time schemes.** ``order`` and ``theta`` select the same schemes as for the semi-Lagrangian solver: @@ -258,14 +268,19 @@ def __init__( transport: str = "eulerian", ): eulerian = transport == "eulerian" - if eulerian and not u_Field.continuous: + if not u_Field.continuous: raise ValueError( - "u_Field must be a continuous MeshVariable: the SUPG weak form " - "is continuous Galerkin." + "u_Field must be a continuous MeshVariable: the weak form is " + "continuous Galerkin (the diffusive flux has no jump terms), " + "whichever transport carries the history." ) if DuDt is not None and not eulerian: raise ValueError("transport chooses the DuDt the solver builds; it cannot " "apply to a DuDt that is supplied") + if not eulerian and peclet_weight != 4.0: + raise ValueError("peclet_weight is the SUPG weight's threshold; it applies to " + "transport='eulerian' only (a semi-Lagrangian history assembles " + "no advection and needs no stabilisation)") ignored = [name for name, value in ( ("restore_points_func", restore_points_func), ("monotone_mode", monotone_mode if eulerian else None), @@ -274,8 +289,12 @@ def __init__( ) if value] if ignored: warnings.warn( - f"AdvDiffusion ignores {', '.join(ignored)}: these configure " - "the semi-Lagrangian trace-back and the Eulerian scheme has none.", + f"AdvDiffusion ignores {', '.join(ignored)}: " + + ("these configure the semi-Lagrangian trace-back and the Eulerian scheme has none." + if eulerian else + "the history manager is built from `transport`; restore_points_func is not " + "forwarded, and a flux history is not carried (the stored-level flux is rebuilt " + "from the carried field)."), stacklevel=2, ) order = int(order) @@ -340,6 +359,17 @@ def __init__( _check_supplied_manager(DuDt, order, theta) self.Unknowns.DuDt = DuDt self._theta = float(getattr(self.DuDt, "theta", theta)) + self._transport = transport + + # A field carried by a nodal trace-back on a mesh that moves is + # transferred by that trace-back (CARRY); re-interpolating it onto the + # new node positions AND subtracting the mesh velocity in the next + # trace-back would compensate the motion twice (the old solver's rule). + from underworld3.systems.ddt import BackwardNodesSemiLagrangian + if isinstance(self.Unknowns.DuDt, BackwardNodesSemiLagrangian): + from underworld3.discretisation.remesh import RemeshPolicy + u_Field.remesh_policy = RemeshPolicy.CARRY + u_Field._remesh_managed_by = self.Unknowns.DuDt # Diffusivity lives on the constitutive model, as for every scalar # solver; kappa = 0 until the user sets it. @@ -478,6 +508,11 @@ def theta(self, value): ) if not hasattr(self.DuDt, "theta"): raise AttributeError(f"{type(self.DuDt).__name__} has no theta to set.") + # at theta = 1 an integration-point history returns literal zero + # weights for the stored level (the compiled form drops it), so a + # change across 1.0 is a change of form, not of a runtime constant + if (value == 1.0) != (self._theta == 1.0): + self._needs_function_rewire = True self._theta = value self.DuDt.theta = value @@ -508,9 +543,28 @@ def V_fn(self): @V_fn.setter def V_fn(self, value): - self.DuDt.V_fn = _as_row_vector(value, self.mesh.dim) + value = _as_row_vector(value, self.mesh.dim) + self.DuDt.V_fn = value + # a semi-Lagrangian history's characteristic trace captured V_fn at + # its first step; tell it too + trace = getattr(self.DuDt, "_characteristics", None) + if trace is not None and hasattr(trace, "V_fn"): + trace.V_fn = value self.is_setup = False + @property + def transport(self) -> str: + """How the history is carried: ``"eulerian"`` (SUPG on the mesh) or a + semi-Lagrangian scheme (constructor choice).""" + return self._transport + + def _supg_only(self, name): + from underworld3.systems.ddt import EulerianSUPG + if not isinstance(self.DuDt, EulerianSUPG): + raise ValueError(f"{name} belongs to the SUPG scheme (transport='eulerian'); " + f"this solver carries its history by {type(self.DuDt).__name__}, " + "which assembles no advection and has no stabilisation") + @property def f(self): """Volumetric source term.""" @@ -526,24 +580,29 @@ def f(self, value): @property def peclet_weight(self) -> float: """The critical cell Péclet number of the weight (constructor choice; 0 = uniform).""" + self._supg_only("peclet_weight") return self.DuDt.peclet_weight @property def supg_weight(self) -> float: """Scale of the SUPG term: 1 (default) or 0 for plain Galerkin. No rebuild.""" + self._supg_only("supg_weight") return self.DuDt.supg_weight @supg_weight.setter def supg_weight(self, value): + self._supg_only("supg_weight") self.DuDt.supg_weight = value @property def tau_weights(self): r"""The weights :math:`(C_t, C_u, C_\kappa)` of the three terms in :math:`\tau`.""" + self._supg_only("tau_weights") return self.DuDt.tau_weights @tau_weights.setter def tau_weights(self, values): + self._supg_only("tau_weights") self.DuDt.tau_weights = values # ------------------------------------------------------------------ @@ -554,11 +613,14 @@ def _diffusive_flux(self): r"""``(1, dim)`` flux :math:`\sum_k w_k\,\nabla\phi^{(k)}\cdot\kappa` from the constitutive tensor.""" dim = self.mesh.dim c = self.constitutive_model.c + # a stored level without a derivative (the integration-point history) + # is read through its continuous snapshot, as in the Navier-Stokes solver + stand_in = self.DuDt._derivative_stand_ins() total = sympy.zeros(1, dim) for w, phi in zip(self.DuDt.spatial_weights(), self.DuDt.states()): if w == 0: continue - grad = self.mesh.vector.gradient(phi[0]) + grad = self.mesh.vector.gradient(sympy.Matrix(phi).xreplace(stand_in)[0]) total = total + w * (grad * c) return total @@ -596,9 +658,10 @@ def _stabilisation_flux(self): # ------------------------------------------------------------------ @timing.routine_timer_decorator - def estimate_dt(self, fraction: float = 0.02, basis: str = "accuracy", + def estimate_dt(self, fraction: float = 0.02, basis: str = None, direction_aware: bool = False, percentile: float = 0.0): - r"""A timestep for this scheme, chosen for accuracy. + r"""A timestep for this scheme: for accuracy on the Eulerian path, the + cell-crossing time for a semi-Lagrangian one. The implicit scheme has no stability limit, so the cell-crossing time the semi-Lagrangian solver reports says nothing about how large a step @@ -627,10 +690,13 @@ def estimate_dt(self, fraction: float = 0.02, basis: str = "accuracy", ---------- fraction : float, default 0.02 Allowed change of the field per step as a fraction of its range. - basis : {"accuracy", "resolution"} + basis : {"accuracy", "resolution"}, default by transport ``"resolution"`` returns the cell-crossing / diffusion time the semi-Lagrangian solver's ``estimate_dt`` returns, for scripts that - size the step in Courant numbers. + size the step in Courant numbers; it is the default for a + semi-Lagrangian transport, whose error is flat in the step + (``"accuracy"`` would return ``inf`` for a uniform start). + ``"accuracy"`` is the default for ``transport="eulerian"``. direction_aware, percentile Forwarded to the resolution estimate; ignored otherwise. @@ -642,6 +708,8 @@ def estimate_dt(self, fraction: float = 0.02, basis: str = "accuracy", """ from mpi4py import MPI + if basis is None: + basis = "accuracy" if self._transport == "eulerian" else "resolution" if basis == "resolution": dt_estimate, dt_adv, dt_diff = _advective_diffusive_dt( self.constitutive_model.K, self.V_fn, self.mesh, diff --git a/src/underworld3/systems/navier_stokes_eulerian.py b/src/underworld3/systems/navier_stokes_eulerian.py index 6bd9a0998..6fce6e5aa 100644 --- a/src/underworld3/systems/navier_stokes_eulerian.py +++ b/src/underworld3/systems/navier_stokes_eulerian.py @@ -11,9 +11,12 @@ counterpart of :class:`~underworld3.systems.AdvDiffusion`. The time scheme is the same multistep family: Crank-Nicolson (the theta rule) at order 1, BDF2 at order 2, with the history held on the mesh by the -Eulerian history manager. No stress history is carried: the viscous stress -at an earlier level is rebuilt from the stored velocity level through the -constitutive model. +Eulerian history manager, or carried by a semi-Lagrangian scheme +(``velocity_transport``). For a viscous fluid no stress history is carried: +the viscous stress at an earlier level is rebuilt from the stored velocity +level through the constitutive model. A viscoelastic model carries its stress +history by the scheme ``stress_transport`` names, and the SUPG residual then +includes the divergence of that memory stress. The advecting velocity :math:`\mathbf{a}` in :math:`(\mathbf{a}\cdot\nabla)\mathbf{u}^{n+1}` is a choice (``advection=``): ``"extrapolated"`` (default) uses @@ -238,13 +241,21 @@ def __init__( # solve: the extrapolation, or the latest Picard iterate) and the # level n-1 the extrapolation needs beyond what the history holds. u = self.Unknowns.u - self._a_var = uw.discretisation.MeshVariable( - f"a_NSSUPG_{tag}", self.mesh, self.mesh.dim, degree=u.degree, - continuous=u.continuous, varsymbol=rf"\mathbf{{a}}_{{{tag}}}") - self._u_prev = uw.discretisation.MeshVariable( - f"u_prev_NSSUPG_{tag}", self.mesh, self.mesh.dim, degree=u.degree, - continuous=u.continuous, varsymbol=rf"\mathbf{{u}}^{{n-1}}_{{{tag}}}") + self._eulerian = velocity_transport == "eulerian" + self._velocity_transport = velocity_transport + # the extrapolation state belongs to the assembled-advection path only + self._a_var = self._u_prev = None + if self._eulerian: + self._a_var = uw.discretisation.MeshVariable( + f"a_NSSUPG_{tag}", self.mesh, self.mesh.dim, degree=u.degree, + continuous=u.continuous, varsymbol=rf"\mathbf{{a}}_{{{tag}}}") + self._u_prev = uw.discretisation.MeshVariable( + f"u_prev_NSSUPG_{tag}", self.mesh, self.mesh.dim, degree=u.degree, + continuous=u.continuous, varsymbol=rf"\mathbf{{u}}^{{n-1}}_{{{tag}}}") self._history_primed = False + if not self._eulerian and peclet_weight != 4.0: + raise ValueError("peclet_weight is the SUPG weight's threshold; it applies to " + "velocity_transport='eulerian' only") # The transport plugin: the history manager owns the time scheme, the # advecting velocity, the assembled advection and the stabilisation. @@ -348,6 +359,7 @@ def picard_iterations(self, value): @property def peclet_weight(self) -> float: """The critical cell Péclet number of the weight (0 = no Péclet weighting).""" + self._supg_only("peclet_weight") return self.DuDt.peclet_weight @property @@ -385,19 +397,23 @@ def delta_t(self, value): @property def supg_weight(self) -> float: """Weight of the SUPG term; 0 gives the plain Galerkin scheme.""" + self._supg_only("supg_weight") return self.DuDt.supg_weight @supg_weight.setter def supg_weight(self, value): + self._supg_only("supg_weight") self.DuDt.supg_weight = value @property def tau_weights(self): """The three weights of tau: transient, advective, viscous.""" + self._supg_only("tau_weights") return self.DuDt.tau_weights @tau_weights.setter def tau_weights(self, values): + self._supg_only("tau_weights") self.DuDt.tau_weights = values # ------------------------------------------------------------------ @@ -406,10 +422,22 @@ def tau_weights(self, values): def _advecting_velocity(self): """The advecting velocity at the new level, as a ``(1, dim)`` row.""" - if self._advection_mode == "implicit": + if self._advection_mode == "implicit" or self._a_var is None: return self.u.sym return self._a_var.sym + @property + def velocity_transport(self) -> str: + """How the velocity history is carried: ``"eulerian"`` (SUPG on the + mesh) or a semi-Lagrangian scheme (constructor choice).""" + return self._velocity_transport + + def _supg_only(self, name): + if not self._eulerian: + raise ValueError(f"{name} belongs to the SUPG scheme (velocity_transport='eulerian'); " + f"this solver carries its velocity history by {type(self.DuDt).__name__}, " + "which assembles no advection and has no stabilisation") + def _strong_residual(self, with_pressure=False): r"""The strong momentum residual of the time scheme, first derivatives only. @@ -547,7 +575,8 @@ def _set_advecting_velocity(self, values): def _prime_history(self): """First solve: the extrapolation level equals the current velocity.""" if not self._history_primed: - self._u_prev.array[...] = self.u.array[...] + if self._u_prev is not None: + self._u_prev.array[...] = self.u.array[...] self._history_primed = True @timing.routine_timer_decorator @@ -594,7 +623,11 @@ def solve( the advecting velocity; with ``"implicit"`` the SNES solves the quadratic term by Newton iteration. """ - for name in ("time", "order", "evalf", "_evalf", "homotopy"): + if "order" in kwargs: + raise ValueError("NavierStokes.solve(order=...) is not an option: the order of the " + "time scheme is fixed at construction (the former NavierStokesSLCN " + "read it per solve)") + for name in ("time", "evalf", "_evalf", "homotopy"): kwargs.pop(name, None) if kwargs: warnings.warn(f"NavierStokes.solve ignores {sorted(kwargs)}", stacklevel=2) @@ -625,12 +658,12 @@ def solve( self._prime_history() u_n = np.array(self.u.array[...]) - if self._advection_mode == "extrapolated": + if self._advection_mode == "extrapolated" and self._eulerian: self._set_advecting_velocity(2.0 * u_n - np.asarray(self._u_prev.array[...])) self.DuDt.update_pre_solve(dt, verbose=verbose) passes = 1 - if self._advection_mode == "extrapolated": + if self._advection_mode == "extrapolated" and self._eulerian: n_picard = self._picard_iterations if picard_iterations is None else int(picard_iterations) passes += max(n_picard, 0) from mpi4py import MPI @@ -668,7 +701,8 @@ def solve( # Shift the extrapolation level (the velocity this step started from), # then the history. - self._u_prev.array[...] = u_n + if self._u_prev is not None: + self._u_prev.array[...] = u_n self.DuDt.update_post_solve(dt, verbose=verbose) self.is_setup = True diff --git a/src/underworld3/systems/solvers.py b/src/underworld3/systems/solvers.py index c5df87218..dd2bf43dc 100644 --- a/src/underworld3/systems/solvers.py +++ b/src/underworld3/systems/solvers.py @@ -480,8 +480,8 @@ def _value_history(transport, mesh, field, V_fn, vtype, order, nodal_options, re take them; ``common`` go to every scheme. """ if transport not in _SEMI_LAGRANGIAN_TRANSPORTS: - raise ValueError(f"transport must be one of {tuple(_SEMI_LAGRANGIAN_TRANSPORTS)}, " - f"not {transport!r}") + raise ValueError(f"transport must be 'eulerian' (the solver's own scheme) or one of " + f"{tuple(_SEMI_LAGRANGIAN_TRANSPORTS)}, not {transport!r}") if transport == "backward_nodes": return BackwardNodesSemiLagrangian( mesh, field.sym, V_fn, vtype=vtype, degree=field.degree, diff --git a/tests/test_0066_integration_point_slcn.py b/tests/test_0066_integration_point_slcn.py index d2b7df65a..fa54e11cd 100644 --- a/tests/test_0066_integration_point_slcn.py +++ b/tests/test_0066_integration_point_slcn.py @@ -186,9 +186,10 @@ def test_composed_advdiffusion_reachability(config): """The composed uw.systems.AdvDiffusion (#688) takes the history as its transport manager. With no spatial term on the old level (BDF2, or theta = 1) the integration-point history runs there and matches the SLCN - solver; with the Crank-Nicolson flux (theta = 0.5) the old level is - differentiated, which a delta field cannot supply, and the JIT guard - refuses with a clear message.""" + solver; with the Crank-Nicolson flux (theta = 0.5) the old level's gradient + is read through the history's continuous snapshot (a delta field has no + derivative), where the SLCN solver traces a flux history, so the two agree + to the order of that difference.""" mesh = uw.meshing.UnstructuredSimplexBox( minCoords=(-1, -1), maxCoords=(1, 1), cellSize=0.1, qdegree=3 ) @@ -210,10 +211,6 @@ def run(solver_cls, kwargs): adv.solve(timestep=0.1) return np.asarray(T.data[:, 0]).copy() - if config == "cn": - with pytest.raises(RuntimeError, match="integration-point"): - run(uw.systems.AdvDiffusion, {}) - return kw = {"theta": theta} if order == 1 else {} T_composed = run(uw.systems.AdvDiffusion, kw) T_slcn = run(uw.systems.AdvDiffusionSLCN, {}) diff --git a/tests/test_1102_forward_nodes_rotating_gaussian.py b/tests/test_1102_forward_nodes_rotating_gaussian.py index 91c16a455..65cf180fa 100644 --- a/tests/test_1102_forward_nodes_rotating_gaussian.py +++ b/tests/test_1102_forward_nodes_rotating_gaussian.py @@ -52,3 +52,51 @@ def test_forward_from_nodes_holds_the_box(): err, peak = _run(mesh, ("Left", "Right", "Top", "Bottom")) assert abs(err - 0.065672) < 1.0e-4, err # BASELINE (2026-09-27) assert abs(peak - EXACT_PEAK) < 0.005, (peak, EXACT_PEAK) + + +# The composed solver with each transport on the same fixture. Hard baselines: the +# stored-level flux is rebuilt from the carried field (an integration-point store +# through its continuous snapshot), so the numbers are the composed solver's own, +# not the SLCN solver's. +COMPOSED_DISC = { + "eulerian": 0.017242, + "backward_nodes": 0.020781, + "backward_integration_points": 0.021344, # theta 0.5: the snapshot stand-in + "forward_nodes": 0.017335, +} + + +def _run_composed(mesh, walls, transport): + x, y = mesh.X + sol = uw.analytic.RotatingGaussian(mesh, sigma=SIGMA, centre_radius=0.5, omega=1.0, diffusivity=KAPPA) + T = uw.discretisation.MeshVariable("T", mesh, 1, degree=2) + T.array[:, 0, 0] = uw.function.evaluate(sol.at(0.0), T.coords).reshape(-1) + adv = uw.systems.AdvDiffusion(mesh, u_Field=T, V_fn=sympy.Matrix([[-y, x]]), order=1, transport=transport) + adv.constitutive_model = uw.constitutive_models.DiffusionModel + adv.constitutive_model.Parameters.diffusivity = KAPPA + for wall in walls: + adv.add_dirichlet_bc(0.0, wall) + dt = 0.02 + nsteps = int(round(T_END / dt)); dt = T_END / nsteps + for _ in range(nsteps): + adv.solve(timestep=dt) + return float(sol.error(sol.at(T_END), T, norm="integral")), float(np.asarray(T.data).max()), adv + + +@pytest.mark.parametrize("transport", list(COMPOSED_DISC)) +def test_the_composed_advdiffusion_carries_each_transport_on_the_disc(transport): + uw.reset_default_model() + err, peak, adv = _run_composed(_disc(24), ("Upper",), transport) + assert abs(err - COMPOSED_DISC[transport]) < 1.0e-4, (transport, err) # BASELINE (2026-10-05) + assert abs(peak - EXACT_PEAK) < 0.003, (peak, EXACT_PEAK) + assert adv.transport == transport + if transport != "eulerian": + with pytest.raises(ValueError, match="eulerian"): + adv.supg_weight = 0.0 + assert np.isfinite(adv.estimate_dt()) # the cell-crossing time, not inf + + +def test_the_composed_advdiffusion_refuses_the_linear_forward_fit_for_a_quadratic_field(): + uw.reset_default_model() + with pytest.raises(NotImplementedError, match="degree must be 1"): + _run_composed(_disc(24), ("Upper",), "forward_integration_points") From fda1286d826dc416d61c68e22fba525ffa72fc9f Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sun, 4 Oct 2026 23:02:43 +1100 Subject: [PATCH 17/21] Review (bf4e663e..59366e96): rank-symmetric FENE-P variables, the projector on an empty rank, and the rest HIGH: the FENE-P fields were named by id(self), which differs across ranks, and variable creation is collective and keyed by name; the first parallel runs deadlocked at their first snapshot. They are named by a counter. The projector's build reshaped an empty cell list with -1 and raised on a rank with no cells; the basis size now comes from the degree. FENE-P with the default (infinite) extensibility was 0/0; it is refused at the first solve, and the spring factor's denominator is floored at one percent of L^2 so a record whose trace passes L^2 by the explicit lag does not turn relaxation into growth. MED: `element` is derived from the solvent viscosity when undeclared and enforced when declared (a Maxwell element with a dashpot is refused). The forward integration-point global path averages the previous field at a seam vertex over the local cells only; it now sums across seams, so the answer is partition-independent as documented. The forward nodal launch weights summed to 1.3 cell measures; each cell now shares its measure among its lattice points and its dofs, so a covered cell reads as covered. The projection's solve checks convergence. The FENE-P refresh reads coords_nd. fit_limiter is a property that refuses the global reconstruction. The per-cell overshoot is reduced over ranks. A user's remesh policy is not overwritten. Tests: an arrival on a shared face is weighed once; the launch weights sum to the domain; the element rules; infinite extensibility refused; the deficit-fill test is serial-only. LOW: stale docstrings on the forward integration-point history, the restart note on the reconstruction options, the smoothing check compares as a number, utilities exports particle_projection, .array writes. Underworld development team with AI support from Claude Code Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo --- src/underworld3/constitutive_models.py | 76 ++++++++++++++----- src/underworld3/systems/__init__.py | 2 +- .../systems/advection_diffusion_eulerian.py | 8 +- src/underworld3/systems/ddt.py | 74 +++++++++++++----- src/underworld3/utilities/__init__.py | 1 + .../utilities/particle_projection.py | 34 ++++++++- tests/test_1057_ddt_transport_plugin.py | 4 +- tests/test_1067_particle_projection.py | 35 +++++++++ tests/test_1068_fene_p.py | 49 ++++++++++-- 9 files changed, 229 insertions(+), 54 deletions(-) diff --git a/src/underworld3/constitutive_models.py b/src/underworld3/constitutive_models.py index bbb32968f..ded079c0d 100644 --- a/src/underworld3/constitutive_models.py +++ b/src/underworld3/constitutive_models.py @@ -1636,6 +1636,8 @@ def _object_viewer(self): #: logarithm of. The positive-definite step never produces a smaller one except #: by round-off, so this only bounds psi; the health check counts where it acts. _CONFORMATION_FLOOR = 1.0e-12 +#: FENE-P: the floor of L^2 - tr c in the spring factor, as a fraction of L^2 +_FENE_FLOOR = 1.0e-2 def _sym2_parts(m): @@ -1668,6 +1670,7 @@ def _logm_sym2(m): class ViscoElasticPlasticFlowModel(ViscousFlowModel): + _fene_instances = 0 r""" Viscoelastic-plastic flow constitutive model. @@ -1692,7 +1695,7 @@ class ViscoElasticPlasticFlowModel(ViscousFlowModel): def __init__(self, unknowns, order=1, integrator: str = "bdf", material_name: str = None, objective_rate: str = "none", stress_history: str = "stress", convected_step: str = None, - element: str = "maxwell", relaxation: str = "linear"): + element: str = None, relaxation: str = "linear"): """Construct a viscoelastic-plastic flow model. Parameters @@ -1749,8 +1752,11 @@ def __init__(self, unknowns, order=1, integrator: str = "bdf", stretching of the relaxation target is completed the same way). Default ``"linear"``, or ``"deformation"`` with the log-conformation history, which requires it. - element : {"maxwell", "jeffreys"}, default "maxwell" + element : {"maxwell", "jeffreys"}, optional The spring-dashpot arrangement, which fixes the linear response. + Left unset, it follows ``Parameters.solvent_viscosity`` (Jeffreys + when non-zero); declared, it is enforced: ``"maxwell"`` with a + non-zero solvent viscosity is refused at the first solve. ``"maxwell"`` is the spring and dashpot in series (the upper- convected Maxwell fluid with the upper-convected rate). ``"jeffreys"`` adds a dashpot in parallel, ``Parameters.solvent_viscosity`` @@ -1820,7 +1826,7 @@ def __init__(self, unknowns, order=1, integrator: str = "bdf", "logarithm and exponential; 3-D is not implemented") self._stress_history = stress_history self._convected_step = convected_step - if element not in ("maxwell", "jeffreys"): + if element not in (None, "maxwell", "jeffreys"): raise ValueError(f"element must be 'maxwell' or 'jeffreys', got {element!r}") if relaxation not in ("linear", "fene_p"): raise ValueError(f"relaxation must be 'linear' or 'fene_p', got {relaxation!r}") @@ -1838,15 +1844,19 @@ def __init__(self, unknowns, order=1, integrator: str = "bdf", # second time through the relaxation coefficient self._fene_x = self._fene_f = None if relaxation == "fene_p": + # named by a counter, the same on every rank and in a restart + # (variable creation is collective and keyed by name, #384) + ViscoElasticPlasticFlowModel._fene_instances += 1 + tag = ViscoElasticPlasticFlowModel._fene_instances self._fene_x = uw.discretisation.MeshVariable( - f"fene_x_{id(self)}", unknowns.u.mesh, 1, degree=1, continuous=True, + f"fene_x_{tag}", unknowns.u.mesh, 1, degree=1, continuous=True, varsymbol=r"{x_{\mathrm{FENE}}}") # the spring factor f(c*) itself, the same way: the flux reads # G (f* c* - I) with f* a field rather than a function of the record self._fene_f = uw.discretisation.MeshVariable( - f"fene_f_{id(self)}", unknowns.u.mesh, 1, degree=1, continuous=True, + f"fene_f_{tag}", unknowns.u.mesh, 1, degree=1, continuous=True, varsymbol=r"{f_{\mathrm{FENE}}}") - self._fene_f.data[:, 0] = 1.0 + self._fene_f.array[:, 0, 0] = 1.0 # Store material_name before creating expressions (needed by create_unique_symbol) self._material_name = material_name @@ -2192,17 +2202,20 @@ def _refresh_fene_x(self): r"""Evaluate :math:`\Delta t\,f(c^*)/\lambda` at the nodes of the FENE-P step field from the carried conformation (explicit: the record as it stands before the solve).""" + if self.Parameters.extensibility.sym is sympy.oo: + raise ValueError("relaxation='fene_p' needs a finite Parameters.extensibility (L^2): " + "with it infinite the spring factor is 0/0") lam = self.Parameters.shear_viscosity_0 / self.Parameters.shear_modulus f_sym = self._peterlin_sym(self._carried_conformation_sym(0)) from underworld3.systems.ddt import _to_nondim_ndarray - coords = np.asarray(self._fene_f.coords) + coords = np.asarray(self._fene_f.coords_nd) f_vals = np.asarray(_to_nondim_ndarray(uw.function.evaluate(f_sym, coords))).reshape(-1) - self._fene_f.data[:, 0] = f_vals + self._fene_f.array[:, 0, 0] = f_vals x_vals = np.asarray(_to_nondim_ndarray(uw.function.evaluate(self.Parameters.dt_elastic / lam, coords))).reshape(-1) - self._fene_x.data[:, 0] = x_vals * f_vals + self._fene_x.array[:, 0, 0] = x_vals * f_vals def _update_history_coefficients(self): - """Pre-solve hook: refresh integrator coefficients. + """Pre-solve hook: check the element and refresh integrator coefficients. Dispatches on ``(self._integrator, self._order)``: - ``"bdf"`` (order 1 or 2): updates BDF c-coefficients via @@ -2213,6 +2226,7 @@ def _update_history_coefficients(self): forces ``φ = α`` so the ``(φ-α)·ε̇*`` term zeros out — fully L-stable single-step, no forcing-history slot needed. """ + self._check_element() if self._integrator == "etd": if self.Unknowns.DFDt is None: return @@ -2284,7 +2298,11 @@ def _peterlin_sym(self, c): return sympy.Integer(1) d = self.Unknowns.u.mesh.dim L2 = self.Parameters.extensibility - return (L2 - d) / (L2 - sympy.Matrix(c).trace()) + # the record's trace can pass L^2 by the explicit lag of f (the step + # stretches the conformation with the previous f); past it f would turn + # negative and the relaxation into growth. The denominator is floored + # at one percent of L^2: f saturates at 100 (L^2 - d)/L^2 instead + return (L2 - d) / sympy.Max(L2 - sympy.Matrix(c).trace(), _FENE_FLOOR * L2) def _peterlin_np(self, c): """:meth:`_peterlin_sym` on an array of conformations (n, d, d).""" @@ -2292,7 +2310,7 @@ def _peterlin_np(self, c): return np.ones(c.shape[0]) d = c.shape[-1] L2 = float(self.Parameters.extensibility.sym) - return (L2 - d) / (L2 - np.trace(c, axis1=1, axis2=2)) + return (L2 - d) / np.maximum(L2 - np.trace(c, axis1=1, axis2=2), _FENE_FLOOR * L2) def _carried_conformation_sym(self, level=0): r"""The carried conformation :math:`c^* = e^{\psi^*}` of the @@ -2512,6 +2530,8 @@ def _carried_stress(self): uw.function.evaluate(self.Parameters.shear_modulus.sym, points))).reshape(-1) w, v = np.linalg.eigh(tau) c = v @ (np.exp(w)[:, :, None] * np.transpose(v, (0, 2, 1))) + # the exact f(c) of the record, where the weak form reads the + # lagged nodal field f*: the two differ by the step's change of f f = self._peterlin_np(c) tau = G[:, None, None] * (f[:, None, None] * c - np.eye(tau.shape[-1])[None]) return tau, points @@ -2987,16 +3007,32 @@ def yield_softness(self, value): self._yield_softness_expr.sym = sympy.Float(self._yield_softness) self._reset() - @property - def element(self) -> str: - """The spring-dashpot arrangement as built: ``"jeffreys"`` whenever the - parallel dashpot (``Parameters.solvent_viscosity``) is non-zero, - ``"maxwell"`` otherwise, whatever was declared.""" + def _has_parallel_dashpot(self): try: - parallel = float(self.Parameters.solvent_viscosity.sym) != 0.0 + return float(self.Parameters.solvent_viscosity.sym) != 0.0 except (TypeError, ValueError): - parallel = True - return "jeffreys" if parallel else "maxwell" + return True # an expression: not provably zero + + @property + def element(self) -> str: + """The spring-dashpot arrangement: as declared at construction, or, + left undeclared, ``"jeffreys"`` when ``Parameters.solvent_viscosity`` + is non-zero and ``"maxwell"`` otherwise. A declared ``"maxwell"`` with + a non-zero solvent viscosity is refused at the first solve.""" + if self._element is not None: + return self._element + return "jeffreys" if self._has_parallel_dashpot() else "maxwell" + + def _check_element(self): + """A declared Maxwell element with a parallel dashpot is a contradiction.""" + if self._element == "maxwell": + try: + parallel = float(self.Parameters.solvent_viscosity.sym) != 0.0 + except (TypeError, ValueError): + parallel = True # an expression: not provably zero + if parallel: + raise ValueError("element='maxwell' has no parallel dashpot: set element='jeffreys' " + "or Parameters.solvent_viscosity = 0") @property def relaxation(self) -> str: diff --git a/src/underworld3/systems/__init__.py b/src/underworld3/systems/__init__.py index 6be879c55..ac44da4ed 100644 --- a/src/underworld3/systems/__init__.py +++ b/src/underworld3/systems/__init__.py @@ -110,7 +110,7 @@ "defaults differ: order 1 (was 2), rho 1 (was 0), p_continuous True (was False), " "no velocity-history smoothing (was 1e-4); estimate_dt returns one number " "(was a (diffusive, advective) pair); the order is fixed at construction " - "(solve(order=) is refused) and flux_order is not an argument"), + "(solve(order=) is refused, where an earlier composed solver dropped it) and flux_order is not an argument"), "NavierStokesSwarm": ("SNES_NavierStokes", "uw.systems.NavierStokes(..., velocity_transport='backward_nodes')", "as for NavierStokesSLCN; a particle velocity history is velocity_transport='lagrangian'"), diff --git a/src/underworld3/systems/advection_diffusion_eulerian.py b/src/underworld3/systems/advection_diffusion_eulerian.py index 0a05f2138..5a255aa2a 100644 --- a/src/underworld3/systems/advection_diffusion_eulerian.py +++ b/src/underworld3/systems/advection_diffusion_eulerian.py @@ -368,8 +368,12 @@ def __init__( from underworld3.systems.ddt import BackwardNodesSemiLagrangian if isinstance(self.Unknowns.DuDt, BackwardNodesSemiLagrangian): from underworld3.discretisation.remesh import RemeshPolicy - u_Field.remesh_policy = RemeshPolicy.CARRY - u_Field._remesh_managed_by = self.Unknowns.DuDt + if getattr(u_Field, "_remesh_managed_by", None) is None: + u_Field.remesh_policy = RemeshPolicy.CARRY + u_Field._remesh_managed_by = self.Unknowns.DuDt + elif u_Field._remesh_managed_by is not self.Unknowns.DuDt: + warnings.warn(f"{u_Field.name} is already remesh-managed by " + f"{type(u_Field._remesh_managed_by).__name__}; left as it is", stacklevel=2) # Diffusivity lives on the constitutive model, as for every scalar # solver; kappa = 0 until the user sets it. diff --git a/src/underworld3/systems/ddt.py b/src/underworld3/systems/ddt.py index fe3ab49ae..b4407a7f2 100644 --- a/src/underworld3/systems/ddt.py +++ b/src/underworld3/systems/ddt.py @@ -5390,6 +5390,14 @@ def update_post_solve(self, dt, evalf=False, verbose=False, **_ignored): self._n_solves_completed += 1 +def _is_zero(value): + """True for a numeric zero (int, float, numpy or sympy); False for a field.""" + try: + return float(value) == 0.0 + except (TypeError, ValueError): + return False + + def _range_excursion(values, u): """How far the reconstructed field ``u`` leaves the range of the carried ``values``, over every rank, relative to that range: a projection is not @@ -5404,7 +5412,8 @@ def _range_excursion(values, u): class ForwardIntegrationPointsSemiLagrangian(_StoreSmoothingMixin, _DDtBase): r"""Semi-Lagrangian history carried forward from a fixed set of launch - points inside the cells, read by the weak form through a per-cell fit. + points inside the cells, read by the weak form through a per-cell fit or + one global projection (:attr:`reconstruction`). The carried field is known at the launch points, the mesh's integration points with their quadrature weights scaled by the cell measure. Each step @@ -5473,8 +5482,8 @@ def __init__( if mesh.cdim != mesh.dim: raise NotImplementedError("ForwardIntegrationPointsSemiLagrangian fits in the embedding coordinates; no manifolds") if _unsupported: - warnings.warn(f"ForwardIntegrationPointsSemiLagrangian ignores {sorted(_unsupported)}: it has one level, " - "a linear fit per cell and no smoothing or monotone option", stacklevel=2) + warnings.warn(f"ForwardIntegrationPointsSemiLagrangian ignores {sorted(_unsupported)}: it has one level " + "and no monotone option (its smoothing is store_smoothing)", stacklevel=2) self.vtype = vtype self.mesh = mesh self.degree = 1 @@ -5526,7 +5535,7 @@ def __init__( self.flux_smoothing = 0.0 self.store_smoothing = store_smoothing # limit each cell's slope to the range of what arrived (see _fit_arrivals) - self.fit_limiter = bool(fit_limiter) + self._fit_limiter = bool(fit_limiter) self._fit_overshoot, self._fit_overshoot_cells = 0.0, 0 self._global_projector = None self._dof_lambda, self._dof_lambda_dm = None, None @@ -5553,6 +5562,22 @@ def _launch_values(self, values): # round trip per component) self._launch_var.data[:, :] = np.asarray(values).reshape(self._launch.shape[0], self.num_components) + @property + def fit_limiter(self) -> bool: + """With ``reconstruction="cell"``: scale each cell's slope so no dof leaves + the range of the values that reached the cell (Barth-Jespersen on the + cell's own arrivals; it clips genuine gradients too, since a cell's + vertices lie outside its interior arrivals). Not an option of the global + projection.""" + return self._fit_limiter + + @fit_limiter.setter + def fit_limiter(self, value): + if value and getattr(self, "_reconstruction", "cell") == "global": + raise ValueError("fit_limiter applies to the per-cell fit (reconstruction='cell'), " + "not the global projection") + self._fit_limiter = bool(value) + @property def reconstruction(self) -> str: """How the arrivals become the field the weak form reads. @@ -5565,7 +5590,9 @@ def reconstruction(self) -> str: no per-cell fit, and a node is interpolated from the arrivals on every side of it. The store keeps its layout either way (the global field is written to every cell's copy of a vertex), so the choice can change - between steps, after a restart included.""" + between steps. It is not part of the snapshot state: after a restart + the script sets ``reconstruction``, ``store_smoothing``, + ``fit_limiter``, ``bubble_penalty`` and ``global_eps`` again.""" return self._reconstruction @reconstruction.setter @@ -5676,7 +5703,8 @@ def _object_viewer(self): super()._object_viewer() display(Latex(r"$\quad\psi = $ " + self.psi_fn._repr_latex_())) display(Latex(r"$\quad\mathbf{v} = $ " + sympy.Matrix(self.V_fn)._repr_latex_())) - display(Latex(r"$\quad$Carried forward from the integration points, fitted per cell")) + display(Latex(r"$\quad$Carried forward from the integration points, " + + ("fitted per cell" if self.reconstruction == "cell" else "projected globally"))) def _evaluate_at_launch(self, expr): """Every stored component of ``expr`` at the launch points, as columns, @@ -5687,7 +5715,7 @@ def _evaluate_at_launch(self, expr): self.mesh, u_Field=self._flux_var, n_components=self.num_components) self._flux_projection.linear_solver(rtol=_HISTORY_PROJECTION_TOLERANCE) self._flux_projection.uw_function = sympy.Matrix([[expr[i, j] for (i, j) in self._components]]) - if self._store_smoothing > 0.0 and not (isinstance(self.flux_smoothing, (int, float)) and self.flux_smoothing == 0.0): + if self._store_smoothing > 0.0 and not _is_zero(self.flux_smoothing): raise ValueError("ForwardIntegrationPointsSemiLagrangian: set store_smoothing (c, alpha = c h^2) " "or flux_smoothing (alpha), not both") self._flux_projection.smoothing = (self._store_smoothing_alpha() if self._store_smoothing > 0.0 @@ -5806,12 +5834,15 @@ def strict(P): excess = np.where(fit_ok[:, None], np.clip(excess, 0.0, None), 0.0) # overshoot of the fit beyond the arrivals' range, relative to the largest # range in the cell set (a diagnostic, recorded with or without the limiter) - scale = max(float(spread.max()) if spread.size else 0.0, 1.0e-300) + comm = uw.mpi.comm + scale = max(comm.allreduce(float(spread.max()) if spread.size else 0.0, op=uw.MPI.MAX), 1.0e-300) if carried: # the carry's fit (the refit at the launch points after a commit - # does not overwrite it) - self._fit_overshoot = float(excess.max()) / scale if excess.size else 0.0 - self._fit_overshoot_cells = int(np.count_nonzero(excess.max(axis=1) > 1.0e-12 * scale)) if excess.size else 0 + # does not overwrite it), reduced over the ranks + local = float(excess.max()) if excess.size else 0.0 + self._fit_overshoot = comm.allreduce(local, op=uw.MPI.MAX) / scale + n_over = int(np.count_nonzero(excess.max(axis=1) > 1.0e-12 * scale)) if excess.size else 0 + self._fit_overshoot_cells = comm.allreduce(n_over, op=uw.MPI.SUM) if self.fit_limiter and fit_ok.any(): # Barth-Jespersen: keep the cell value at the centroid (clipped to the # range), scale the slope by the largest factor that keeps every dof @@ -5874,11 +5905,9 @@ def _project_arrivals(self, X, values, w, cell, inflow, carried): L = self._dof_lambda store = np.asarray(self.psi_star[0].data).reshape(ncell, -1, self.num_components) at_vertices = np.einsum("cij,cjk->cik", self._dof_lambda_inv, store) # (cell, vertex, comp) - old = np.zeros((pj.n_local_rows, self.num_components)) - count = np.zeros(pj.n_local_rows) - np.add.at(old, pj._rows.reshape(-1), at_vertices.reshape(-1, self.num_components)) - np.add.at(count, pj._rows.reshape(-1), 1.0) - old /= np.maximum(count, 1.0)[:, None] + # the mean over the cells sharing a vertex, summed across seams so a + # seam vertex gets the same previous value on every rank + old = pj.average_cell_values_to_rows(at_vertices) u = pj.project(X, values, w, cell, old=old, eps=self.global_eps) self.psi_star[0].data[:, :] = np.einsum("cqi,cik->cqk", L, u[pj._rows]).reshape(-1, self.num_components) if carried: @@ -6124,12 +6153,17 @@ def _weights_of_launch(self): measure = pj.cell_measure ncell = measure.size n_lat = self._interior.shape[0] // max(ncell, 1) - lattice_w = np.repeat(measure / max(n_lat, 1), n_lat) nodes = self._nodes() + # each cell shares its measure equally among its lattice points and + # the store's dofs it holds; a node accumulates a share from every + # cell that contains it. The weights launched from a cell then sum + # to its measure, so a fully covered cell reads as covered and the + # deficit fill starts when arrivals are missing, not before. point, cell, _ = self._projector.containing_cells(nodes) - node_cell = np.zeros(nodes.shape[0], dtype=int) - node_cell[point[::-1]] = cell[::-1] # any containing cell (the first) - node_w = measure[node_cell] / max(n_lat, 1) + per_cell_dofs = np.bincount(cell, minlength=ncell).astype(float) + share = measure / (n_lat + np.maximum(per_cell_dofs, 1.0)) + lattice_w = np.repeat(share, n_lat) + node_w = np.bincount(point, weights=share[cell], minlength=nodes.shape[0]) self._launch_weights = (node_w, lattice_w) node_w, lattice_w = self._launch_weights return np.concatenate([node_w[self._owned], lattice_w]) diff --git a/src/underworld3/utilities/__init__.py b/src/underworld3/utilities/__init__.py index 75891f486..f4d5a0101 100644 --- a/src/underworld3/utilities/__init__.py +++ b/src/underworld3/utilities/__init__.py @@ -99,3 +99,4 @@ def _append_petsc_path(): from . import fault_split from . import line_cut from . import reconnect +from . import particle_projection diff --git a/src/underworld3/utilities/particle_projection.py b/src/underworld3/utilities/particle_projection.py index a567d3b55..5598fe861 100644 --- a/src/underworld3/utilities/particle_projection.py +++ b/src/underworld3/utilities/particle_projection.py @@ -125,8 +125,10 @@ def _build(self): rows[c - c0].extend(sec.getOffset(int(e)) for e in edges) pairs.append([[local[int(q)] for q in dm.getCone(int(e))] for e in edges]) self._edge_pairs = np.asarray(pairs, dtype=np.int64).reshape(-1, 3, 2) - self._rows = np.asarray(rows, dtype=np.int32).reshape(len(cells), -1) - self._nb = self._rows.shape[1] + # the basis size comes from the degree, not from the data: a rank with no + # cells must still build (the setter and project() are collective) + self._nb = d + 1 + (3 if self.degree == 2 else 0) + self._rows = np.asarray(rows, dtype=np.int32).reshape(len(cells), self._nb) Xv = np.array([[coords[csec.getOffset(int(p)) // mesh.cdim, :d] for p in row] for row in cells]) self._Xv = Xv self._x0 = Xv[:, 0, :] if cells.size else np.zeros((0, d)) @@ -275,6 +277,34 @@ def project(self, X, values, weights, cell, old=None, eps=1.0e-8, alpha=None, fi self._sub.localToGlobal(self._lb, self._gb, addv=PETSc.InsertMode.ADD_VALUES) self._gx.zeroEntries() self._ksp.solve(self._gb, self._gx) + if self._ksp.getConvergedReason() <= 0: + raise RuntimeError(f"ParticleL2Projector: the projection's solve did not converge " + f"(reason {self._ksp.getConvergedReason()}, {self._ksp.getIterationNumber()} " + "iterations); a row no point and no previous field reaches is singular") self._sub.globalToLocal(self._gx, self._lx) out[:, j] = self._lx.getArray() return out + + def average_cell_values_to_rows(self, per_cell): + """The mean over the cells sharing each row of per-cell values at the + cell's rows, ``per_cell`` (ncell, nb, k), summed across partition seams + so a shared row gets the same mean on every rank: (n_local_rows, k).""" + if self._dm is not self.mesh.dm: + self._build() + per_cell = np.asarray(per_cell, dtype=float).reshape(self._rows.shape[0], self._nb, -1) + k = per_cell.shape[2] + out = np.zeros((self.n_local_rows, k)) + for j in range(k + 1): + self._lb.zeroEntries() + b = self._lb.getArray() + src = per_cell[:, :, j].ravel() if j < k else np.ones(self._rows.size) + np.add.at(b, self._rows.ravel(), src) + self._lb.setArray(b) + self._gb.zeroEntries() + self._sub.localToGlobal(self._lb, self._gb, addv=PETSc.InsertMode.ADD_VALUES) + self._sub.globalToLocal(self._gb, self._lx) + if j < k: + out[:, j] = self._lx.getArray() + else: + count = np.maximum(self._lx.getArray(), 1.0) + return out / count[:, None] diff --git a/tests/test_1057_ddt_transport_plugin.py b/tests/test_1057_ddt_transport_plugin.py index a3953a1a7..e06e4b66b 100644 --- a/tests/test_1057_ddt_transport_plugin.py +++ b/tests/test_1057_ddt_transport_plugin.py @@ -122,8 +122,8 @@ def field(tag): plug = uw.systems.AdvDiffusion(mesh, T_plug, V, DuDt=history) assert plug.DuDt is history and plug.integrator == "am" and plug.order == 1 assert _is_zero(plug.DuDt.advection()) and _is_zero(plug._stabilisation_flux()) - with pytest.raises(AttributeError): - plug.supg_weight # no stabilisation knobs on this manager + with pytest.raises(ValueError, match="eulerian"): + plug.supg_weight # the stabilisation knobs belong to the SUPG scheme T_slcn = field("slcn") slcn = uw.systems.AdvDiffusionSLCN(mesh, T_slcn, V) diff --git a/tests/test_1067_particle_projection.py b/tests/test_1067_particle_projection.py index 56811306b..46b6f8eae 100644 --- a/tests/test_1067_particle_projection.py +++ b/tests/test_1067_particle_projection.py @@ -145,6 +145,7 @@ def test_the_bubble_penalty_acts_on_the_quadratic_content_only(): assert 1.5e-3 < errs[1000.0] < 3.0e-3 # the quadratic content of x^2 on h ~ 0.1 cells +@pytest.mark.skipif(uw.mpi.size > 1, reason="selects rows by position; serial only") def test_the_deficit_fill_holds_an_emptied_cell_to_the_previous_field(): """Half the cells receive no points at all: their rows take the previous field at full weight (not a 1e-8 pull), while the sampled half is fitted.""" @@ -171,6 +172,40 @@ def test_the_deficit_fill_holds_an_emptied_cell_to_the_previous_field(): assert np.abs(u0 - 3.0).max() < 1.0e-9 +def test_an_arrival_on_a_shared_face_is_counted_once(): + """A point on a face shared by two cells is offered to both; the global + projection must weigh it once, so the multiplicity it comes back with is 2 + and the weight split is w/2 per row.""" + mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.25, qdegree=3) + T = uw.discretisation.MeshVariable("T_face", mesh, 1, degree=1) + history = uw.systems.ddt.ForwardNodesSemiLagrangian(mesh, T.sym, sympy.Matrix([[0.0, 0.0]]), + reconstruction="global") + pj = history._global_projector + pj._build() + # the midpoint of an interior edge: the edge of cell 0 whose two vertices + # are both inside the box + Xv = pj._Xv[0] + inner = [k for k in range(3) if all(1e-9 < c < 1 - 1e-9 for c in Xv[k])] + a, b = (inner + [k for k in range(3) if k not in inner])[:2] + X = 0.5 * (Xv[a] + Xv[b])[None, :] + arrivals, vw, cells, mult = history._arrivals_by_cell(X, np.array([[7.0, 3.0]])) + n_cells_sharing = len(np.unique(cells)) + assert n_cells_sharing >= 1 and len(cells) == n_cells_sharing + assert np.all(mult == n_cells_sharing) + assert abs((vw[:, 1] / mult).sum() - 3.0) < 1e-12 # the weight column, split once over its takers + + +def test_the_forward_nodes_launch_weights_sum_to_the_domain(): + mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.25, qdegree=3) + T = uw.discretisation.MeshVariable("T_w", mesh, 1, degree=1) + history = uw.systems.ddt.ForwardNodesSemiLagrangian(mesh, T.sym, sympy.Matrix([[0.0, 0.0]]), + reconstruction="global") + w = history._weights_of_launch() + node_w, lattice_w = history._launch_weights + assert abs(node_w.sum() + lattice_w.sum() - history._global_projector.cell_measure.sum()) < 1e-12 + assert w.shape[0] == int(history._owned.sum()) + history._interior.shape[0] + + def test_the_global_projection_refuses_a_cubic_store(): mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.25, qdegree=3) with pytest.raises(NotImplementedError, match="degree 3"): diff --git a/tests/test_1068_fene_p.py b/tests/test_1068_fene_p.py index e3a7120c1..9de3e126d 100644 --- a/tests/test_1068_fene_p.py +++ b/tests/test_1068_fene_p.py @@ -30,7 +30,7 @@ def shear_box(relaxation, L2, dt, steps, tag): stokes.stress_transport = "backward_nodes" stokes.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( stokes.Unknowns, order=1, integrator="etd", objective_rate="upper_convected", - stress_history="log_conformation", relaxation=relaxation, element="maxwell") + stress_history="log_conformation", relaxation=relaxation, element="jeffreys") cm = stokes.constitutive_model cm.Parameters.shear_viscosity_0 = 1.0 cm.Parameters.shear_modulus = 1.0 @@ -79,18 +79,53 @@ def test_fene_p_with_infinite_extensibility_is_oldroyd_b(): assert abs(xy - xy_ob) < 1e-8 and abs(n1 - n1_ob) < 1e-8 -def test_the_element_reports_the_parallel_dashpot(): - mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.5, qdegree=3) +def test_the_element_is_what_was_declared_and_a_maxwell_element_refuses_a_parallel_dashpot(): + mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(-1.0, -0.5), maxCoords=(1.0, 0.5), cellSize=0.5, qdegree=3) v = uw.discretisation.MeshVariable("U_elem", mesh, 2, degree=2) p = uw.discretisation.MeshVariable("P_elem", mesh, 1, degree=1) stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + stokes.stress_transport = "backward_nodes" + stokes.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + stokes.Unknowns, order=1, integrator="etd", objective_rate="upper_convected", element="jeffreys") + cm = stokes.constitutive_model + assert cm.element == "jeffreys" and cm.relaxation == "linear" + with pytest.raises(ValueError, match="element"): + uw.constitutive_models.ViscoElasticPlasticFlowModel(stokes.Unknowns, element="burgers") + # undeclared, the element follows the solvent viscosity cm = uw.constitutive_models.ViscoElasticPlasticFlowModel(stokes.Unknowns, order=1, integrator="etd", - objective_rate="upper_convected", element="jeffreys") - assert cm.element == "maxwell" and cm.relaxation == "linear" # no parallel dashpot yet + objective_rate="upper_convected") + assert cm.element == "maxwell" cm.Parameters.solvent_viscosity = 0.5 assert cm.element == "jeffreys" - with pytest.raises(ValueError, match="element"): - uw.constitutive_models.ViscoElasticPlasticFlowModel(stokes.Unknowns, element="burgers") + # a declared Maxwell element given a parallel dashpot is refused at the first solve + stokes.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + stokes.Unknowns, order=1, integrator="etd", objective_rate="upper_convected", element="maxwell") + cm = stokes.constitutive_model + cm.Parameters.shear_viscosity_0 = 1.0 + cm.Parameters.shear_modulus = 1.0 + cm.Parameters.solvent_viscosity = 0.5 + stokes.add_dirichlet_bc((0.5, 0.0), "Top") + stokes.add_dirichlet_bc((-0.5, 0.0), "Bottom") + with pytest.raises(ValueError, match="parallel dashpot"): + stokes.solve(timestep=0.1) + + +def test_fene_p_refuses_an_infinite_extensibility(): + mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(-1.0, -0.5), maxCoords=(1.0, 0.5), cellSize=0.5, qdegree=3) + v = uw.discretisation.MeshVariable("U_inf", mesh, 2, degree=2) + p = uw.discretisation.MeshVariable("P_inf", mesh, 1, degree=1) + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + stokes.stress_transport = "backward_nodes" + stokes.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + stokes.Unknowns, order=1, integrator="etd", objective_rate="upper_convected", + stress_history="log_conformation", element="jeffreys", relaxation="fene_p") + cm = stokes.constitutive_model + cm.Parameters.shear_viscosity_0 = 1.0 + cm.Parameters.shear_modulus = 1.0 + stokes.add_dirichlet_bc((0.5, 0.0), "Top") + stokes.add_dirichlet_bc((-0.5, 0.0), "Bottom") + with pytest.raises(ValueError, match="extensibility"): + stokes.solve(timestep=0.1) def test_fene_p_needs_the_log_conformation_etd_path(): From d4e6fa56aaa86260fc0645f2514917477706d123 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sun, 4 Oct 2026 23:27:09 +1100 Subject: [PATCH 18/21] Global projection: the deficit fill only for cells that have emptied; the forward nodal launch weights summed across seams The fill of a cell's uncovered share from the previous field acted on any shortfall. In a flow every cell's received weight fluctuates about its measure, so about half the cells were pulled a few percent towards the previous, un-advected field each step: a lag that acted as diffusion (a quarter turn of the rotating Gaussian lost 18% of its peak with the forward nodal history and 6% with the integration-point one). The fill now starts at half the measure and is complete at zero: an ordinarily covered cell feels nothing, an emptied cell is held by what it carried. The Gaussian's peak is back above the per-cell fit's (0.786 against 0.769). The forward nodal history's launch weights summed each dof's shares over the rank's own cells, so a seam node launched by its owner carried half its share and the parallel field differed from serial; the shares are summed across the seams through the projector's assembly. test_1066 carries both forward histories with the global projection against serial baselines (np 3 equals serial to 1e-6 for the stress box and the rotating Gaussian). Underworld development team with AI support from Claude Code Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo --- src/underworld3/systems/ddt.py | 18 +++---- .../utilities/particle_projection.py | 50 +++++++++++++++---- .../test_1062_forward_stress_history_mpi.py | 4 ++ .../test_1066_transport_schemes_mpi.py | 10 +++- 4 files changed, 63 insertions(+), 19 deletions(-) diff --git a/src/underworld3/systems/ddt.py b/src/underworld3/systems/ddt.py index b4407a7f2..2ef09e40d 100644 --- a/src/underworld3/systems/ddt.py +++ b/src/underworld3/systems/ddt.py @@ -6153,17 +6153,17 @@ def _weights_of_launch(self): measure = pj.cell_measure ncell = measure.size n_lat = self._interior.shape[0] // max(ncell, 1) - nodes = self._nodes() # each cell shares its measure equally among its lattice points and - # the store's dofs it holds; a node accumulates a share from every - # cell that contains it. The weights launched from a cell then sum - # to its measure, so a fully covered cell reads as covered and the - # deficit fill starts when arrivals are missing, not before. - point, cell, _ = self._projector.containing_cells(nodes) - per_cell_dofs = np.bincount(cell, minlength=ncell).astype(float) - share = measure / (n_lat + np.maximum(per_cell_dofs, 1.0)) + # the store's dofs on it; a dof accumulates a share from every cell + # that holds it, on every rank (summed across the seams, so a seam + # node launched by its owner carries the shares of the cells on + # both sides). The weights launched from a cell then sum to its + # measure, so a fully covered cell reads as covered and the deficit + # fill starts when arrivals are missing, not before. + share = measure / (n_lat + pj._nb) lattice_w = np.repeat(share, n_lat) - node_w = np.bincount(point, weights=share[cell], minlength=nodes.shape[0]) + per_cell = np.broadcast_to(share[:, None, None], (ncell, pj._nb, 1)) + node_w = pj.sum_cell_values_to_rows(per_cell)[:, 0] self._launch_weights = (node_w, lattice_w) node_w, lattice_w = self._launch_weights return np.concatenate([node_w[self._owned], lattice_w]) diff --git a/src/underworld3/utilities/particle_projection.py b/src/underworld3/utilities/particle_projection.py index 5598fe861..772e2918b 100644 --- a/src/underworld3/utilities/particle_projection.py +++ b/src/underworld3/utilities/particle_projection.py @@ -66,6 +66,9 @@ class ParticleL2Projector: """ instances = 0 + #: the covered fraction of a cell's measure below which the previous field + #: fills the shortfall (fully at zero) + FILL_BELOW = 0.5 def __init__(self, mesh, degree=1, rtol=1.0e-12): if degree not in (1, 2): @@ -227,14 +230,15 @@ def project(self, X, values, weights, cell, old=None, eps=1.0e-8, alpha=None, fi finite-element mass matrix (what keeps a row no point reaches at all); ``alpha`` (ncell,) the gradient penalty per cell. - With ``fill_deficit`` (and ``old``), the share of each cell's measure - that the arriving weights do not cover is supplied by the previous - field: that share enters as its finite-element mass with ``old`` as - the data. A cell the flow has emptied is then determined by what it - held, at full weight, rather than left to its neighbours and a - 1e-8 pull; a fully covered cell is unchanged. The weights are the - points' shares of the domain, so the sum over a cell measures how - much of it was reached.""" + With ``fill_deficit`` (and ``old``), a cell whose arriving weights + cover less than :attr:`FILL_BELOW` of its measure is held by the + previous field: the shortfall below that fraction enters as its + finite-element mass with ``old`` as the data, fully at zero coverage. + A cell the flow has emptied is then determined by what it held rather + than left to its neighbours and a 1e-8 pull; an ordinarily covered + cell, whose received weight fluctuates about its measure in a flow, is + unchanged. The weights are the points' shares of the domain, so the + sum over a cell measures how much of it was reached.""" if self._dm is not self.mesh.dm: self._build() values = np.asarray(values, dtype=float) @@ -251,8 +255,16 @@ def project(self, X, values, weights, cell, old=None, eps=1.0e-8, alpha=None, fi np.add.at(Re, cell, w[:, None, None] * phi[:, :, None] * values[:, None, :]) pull = np.full(ncell, float(eps)) if fill_deficit and old is not None: + # In a flow every cell's received weight fluctuates about its + # measure, so a fill of any shortfall would pull about half the cells + # a few percent towards the previous (un-advected) field each step: a + # lag that acts as diffusion (a quarter turn of a Gaussian lost 18% of + # its peak). The fill therefore starts at half the measure and is + # complete at zero: an ordinarily covered cell feels nothing, an + # emptied cell is held by what it carried. received = np.bincount(cell, weights=w, minlength=ncell) - pull = pull + np.clip(1.0 - received / np.maximum(self.cell_measure, 1.0e-300), 0.0, 1.0) + covered = received / np.maximum(self.cell_measure, 1.0e-300) + pull = pull + np.clip((self.FILL_BELOW - covered) / self.FILL_BELOW, 0.0, 1.0) if np.any(pull > 0.0): Me = Me + pull[:, None, None] * self._Me if old is not None: @@ -285,6 +297,26 @@ def project(self, X, values, weights, cell, old=None, eps=1.0e-8, alpha=None, fi out[:, j] = self._lx.getArray() return out + def sum_cell_values_to_rows(self, per_cell): + """The sum over the cells sharing each row of per-cell values at the + cell's rows, ``per_cell`` (ncell, nb, k), summed across partition seams: + (n_local_rows, k), the same on every rank that holds the row.""" + if self._dm is not self.mesh.dm: + self._build() + per_cell = np.asarray(per_cell, dtype=float).reshape(self._rows.shape[0], self._nb, -1) + k = per_cell.shape[2] + out = np.zeros((self.n_local_rows, k)) + for j in range(k): + self._lb.zeroEntries() + b = self._lb.getArray() + np.add.at(b, self._rows.ravel(), per_cell[:, :, j].ravel()) + self._lb.setArray(b) + self._gb.zeroEntries() + self._sub.localToGlobal(self._lb, self._gb, addv=PETSc.InsertMode.ADD_VALUES) + self._sub.globalToLocal(self._gb, self._lx) + out[:, j] = self._lx.getArray() + return out + def average_cell_values_to_rows(self, per_cell): """The mean over the cells sharing each row of per-cell values at the cell's rows, ``per_cell`` (ncell, nb, k), summed across partition seams diff --git a/tests/parallel/test_1062_forward_stress_history_mpi.py b/tests/parallel/test_1062_forward_stress_history_mpi.py index 8ef5dcf46..e3fb62dc6 100644 --- a/tests/parallel/test_1062_forward_stress_history_mpi.py +++ b/tests/parallel/test_1062_forward_stress_history_mpi.py @@ -29,8 +29,12 @@ def turned_over_maxwell_box(transport="forward_integration_points", steps=10, dt v = uw.discretisation.MeshVariable("U_to", mesh, 2, degree=2) p = uw.discretisation.MeshVariable("P_to", mesh, 1, degree=1) stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + # "forward_nodes:global" names a reconstruction of the forward history + transport, _, reconstruction = transport.partition(":") stokes.stress_transport = transport stokes.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel(stokes.Unknowns, order=1) + if reconstruction: + stokes.DFDt.reconstruction = reconstruction stokes.constitutive_model.Parameters.shear_viscosity_0 = 1.0 stokes.constitutive_model.Parameters.shear_modulus = 1.0 + 0.5 * sympy.sin(sympy.pi * x / Lx) stokes.constitutive_model.Parameters.dt_elastic = dt diff --git a/tests/parallel/test_1066_transport_schemes_mpi.py b/tests/parallel/test_1066_transport_schemes_mpi.py index cbd2c58e1..658be8c9e 100644 --- a/tests/parallel/test_1066_transport_schemes_mpi.py +++ b/tests/parallel/test_1066_transport_schemes_mpi.py @@ -39,12 +39,17 @@ "forward_nodes": (-0.1190115, 0.0701718), "lagrangian": (-0.1214269, 0.0672075), "eulerian": (-0.1186881, 0.0699410), + # the global projection of the forward histories (2026-10-05) + "forward_integration_points:global": (-0.1189135, 0.0709336), + "forward_nodes:global": (-0.1190056, 0.0679288), } ADVECTED_T = { "backward_nodes": (0.7259154, 0.0751478), "backward_integration_points": (0.7533908, 0.0739654), "forward_integration_points": (0.6347829, 0.0836136), "forward_nodes": (0.7694358, 0.0721413), + "forward_integration_points:global": (0.6278635, 0.0857784), + "forward_nodes:global": (0.7855832, 0.0711712), } # np 3, 4 and 6 give the serial values to the 7 figures recorded: the history # projections are converged to 1e-10 and no stage depends on which rank, or @@ -58,12 +63,15 @@ def rotating_gaussian(transport, steps=16, dt=np.pi / 32): mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(-1.0, -1.0), maxCoords=(1.0, 1.0), cellSize=0.1, qdegree=3, regular=False) x, y = mesh.X + transport, _, reconstruction = transport.partition(":") # "forward_nodes:global" degree = 1 if transport == "forward_integration_points" else 2 T = uw.discretisation.MeshVariable("T_rg", mesh, 1, degree=degree) T.array[:, 0, 0] = uw.function.evaluate( sympy.exp(-((x - 0.5) ** 2 + y ** 2) / (2 * 0.1 ** 2)), T.coords).reshape(-1) adv = uw.systems.AdvDiffusionSLCN(mesh, u_Field=T, V_fn=sympy.Matrix([[-y, x]]), order=1, transport=transport) + if reconstruction: + adv.DuDt.reconstruction = reconstruction if adv.DuDt.applies_inflow_value: adv.DuDt.inflow_value = sympy.Matrix([[0.0]]) adv.constitutive_model = uw.constitutive_models.DiffusionModel @@ -84,7 +92,7 @@ def rotating_gaussian(transport, steps=16, dt=np.pi / 32): def test_every_stress_history_gives_the_serial_stress_on_every_rank(transport): uw.reset_default_model() _kind, values, relocated = turned_over_maxwell_box(transport) - if transport.startswith("forward_"): + if transport.startswith("forward_integration"): assert relocated > 0 # the seams were crossed, so the exchange ran assert np.allclose(values, STRESS_XY[transport], atol=ATOL), (transport, values) From 31709294f55b4abb0229c202eaec011cac9b4fa8 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Mon, 5 Oct 2026 13:05:29 +1100 Subject: [PATCH 19/21] FENE-P: store log(f c), decode the conformation through the record's trace The log-conformation record for FENE-P was log c with c = (sigma/G + I)/f*, f* the lagged nodal spring factor. The conformation is bounded (tr c < L^2) and the nodal projection that commits the record is not: at the cylinder wall, where log c jumps by 4 across one cell, the projection overshot by a factor 1.5 in c and put the record on the saturation floor (tr c 105 of L^2 100 from a stress whose own conformation had 70). The step then alternated between the saturated and the free spring and the velocity multigrid stalled: the FENE-P "hang" at step ~50 on the cylinder at Wi 1 (res 20) and Wi 0.5 (res 40). The record is now log(sigma/G + I) for both relaxation laws: log c for a linear spring, log(f c) for FENE-P. The decode recovers the conformation through the trace, f* = 1 + (tr e^psi - d)/L^2 and c* = e^psi/f*, so every SPD record is an admissible conformation whatever the projection did. The stress reads as G(e^psi - I) for both laws; the relaxation rate keeps its first-order lag in f*. Shear-box error constants are unchanged (+0.0068/+0.0094 at dt 0.1, halving at dt 0.05). Re 100 Wi 1 res 20 on the cylinder now runs through the ramp and on (the previous record hung at step 54); the record's spring factor at the wall sits 60% above the stress's own, which is the projection overshoot made harmless. A FENE-P snapshot written by the previous record decodes wrongly under this one; none of those runs survived to be worth restarting. Underworld development team with AI support from Claude Code Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo --- docs/developer/subsystems/stress-transport.md | 13 +- src/underworld3/constitutive_models.py | 125 ++++++++++++------ tests/test_1068_fene_p.py | 32 +++-- 3 files changed, 116 insertions(+), 54 deletions(-) diff --git a/docs/developer/subsystems/stress-transport.md b/docs/developer/subsystems/stress-transport.md index 90601065a..a0fc8851e 100644 --- a/docs/developer/subsystems/stress-transport.md +++ b/docs/developer/subsystems/stress-transport.md @@ -258,7 +258,18 @@ does not enter the compiled flux through the coefficients). Two things in the step differ from the linear spring: the stretching source acts on $G(c^* - I)$, which is the carried stress only for a linear spring; and the $f^*$ on the relaxation rate cancels against the $f^*$ on the stress for every source, so -the sources keep their Oldroyd-B weights. Against the closed-form steady +the sources keep their Oldroyd-B weights. The record is $\log(\sigma/G + I)$, +the same as for a linear spring; for FENE-P that is $\log(f c)$, not $\log c$, +and the decode recovers the conformation through the trace, +$f^* = 1 + (\mathrm{tr}\,e^{\psi^*} - d)/L^2$, $c^* = e^{\psi^*}/f^*$. The +conformation is bounded and the nodal projection that commits the record is +not: at a wall where $\log c$ jumps by 4 across one cell it overshoots by a +factor 1.5 in $c$, which for a linear spring is a ringing of the stress and for +a capped spring put the record on the saturation floor (a record trace of 105 +with $L^2 = 100$ from a stress whose own conformation had 70; the step then +alternated between the saturated and the free spring and the velocity solve +stalled). $\log(f c)$ carries no bound, and every SPD record decodes to an +admissible conformation. Only the relaxation rate keeps the lag. Against the closed-form steady simple shear ($f^2 (f - 1) = 2\,\mathrm{Wi}^2/L^2$) the scheme is first order in $\Delta t/\lambda$; with infinite extensibility it is Oldroyd-B to 1e-8. It needs the log-conformation history, the exponential integrator at order 1 diff --git a/src/underworld3/constitutive_models.py b/src/underworld3/constitutive_models.py index ded079c0d..ecbe83a40 100644 --- a/src/underworld3/constitutive_models.py +++ b/src/underworld3/constitutive_models.py @@ -2199,14 +2199,14 @@ def _spring_factor_sym(self): return sympy.Integer(1) def _refresh_fene_x(self): - r"""Evaluate :math:`\Delta t\,f(c^*)/\lambda` at the nodes of the FENE-P - step field from the carried conformation (explicit: the record as it - stands before the solve).""" + r"""Evaluate :math:`f^*` and :math:`\Delta t\,f^*/\lambda` at the nodes of + the FENE-P fields from the record as it stands before the solve + (explicit in the rate, first order): :math:`f^* = 1 + (\mathrm{tr}\,e^{\psi^*} - d)/L^2`.""" if self.Parameters.extensibility.sym is sympy.oo: raise ValueError("relaxation='fene_p' needs a finite Parameters.extensibility (L^2): " "with it infinite the spring factor is 0/0") lam = self.Parameters.shear_viscosity_0 / self.Parameters.shear_modulus - f_sym = self._peterlin_sym(self._carried_conformation_sym(0)) + f_sym = self._fene_spring_factor_of_record(self._carried_record_sym(0)) from underworld3.systems.ddt import _to_nondim_ndarray coords = np.asarray(self._fene_f.coords_nd) f_vals = np.asarray(_to_nondim_ndarray(uw.function.evaluate(f_sym, coords))).reshape(-1) @@ -2298,10 +2298,10 @@ def _peterlin_sym(self, c): return sympy.Integer(1) d = self.Unknowns.u.mesh.dim L2 = self.Parameters.extensibility - # the record's trace can pass L^2 by the explicit lag of f (the step - # stretches the conformation with the previous f); past it f would turn - # negative and the relaxation into growth. The denominator is floored - # at one percent of L^2: f saturates at 100 (L^2 - d)/L^2 instead + # a conformation decoded from the record has tr c < L^2 by construction + # (:meth:`_fene_spring_factor_of_record`); this form is for a conformation + # given directly, where past L^2 f would turn negative. The denominator + # is floored at one percent of L^2: f saturates at 100 (L^2 - d)/L^2 return (L2 - d) / sympy.Max(L2 - sympy.Matrix(c).trace(), _FENE_FLOOR * L2) def _peterlin_np(self, c): @@ -2312,11 +2312,24 @@ def _peterlin_np(self, c): L2 = float(self.Parameters.extensibility.sym) return (L2 - d) / np.maximum(L2 - np.trace(c, axis1=1, axis2=2), _FENE_FLOOR * L2) - def _carried_conformation_sym(self, level=0): - r"""The carried conformation :math:`c^* = e^{\psi^*}` of the - log-conformation history at ``level``.""" + def _carried_record_sym(self, level=0): + r"""The decoded record :math:`e^{\psi^*}` of the log-conformation history + at ``level``: the conformation :math:`c^*` of a linear spring, and + :math:`f^* c^* = \sigma^*/G + I` for FENE-P (see :meth:`encode_history`).""" return _expm_sym2(sympy.Matrix(self.Unknowns.DFDt.psi_star[level].sym)) + def _carried_conformation_sym(self, level=0): + r"""The carried conformation :math:`c^*` of the log-conformation history + at ``level``: the record itself for a linear spring, the record divided + by the spring factor it implies for FENE-P (level 0 only, where the + nodal field :meth:`_refresh_fene_x` fills holds that factor).""" + record = self._carried_record_sym(level) + if self._relaxation != "fene_p": + return record + if level != 0: + raise NotImplementedError("the FENE-P conformation is decoded for history level 0 only") + return record / self._fene_f.sym[0] + def _carried_strain_sym(self, level=0): r"""The carried elastic strain in stress units, :math:`G(c^* - I)`: what the objective rate's source acts on. It is the carried stress itself for @@ -2330,45 +2343,71 @@ def _carried_strain_sym(self, level=0): def _carried_stress_sym(self, level=0): r"""The carried stress :math:`\sigma^*` of history level ``level``: the - stored level, or :math:`G(f(c^*)\,c^* - I)` with :math:`c^* = e^{\psi^*}` - for the log-conformation history (:math:`f = 1` for Oldroyd-B; the - modulus as its expression, so a read of it carries units).""" + stored level, or :math:`G(e^{\psi^*} - I)` for the log-conformation + history (the modulus as its expression, so a read of it carries units).""" stored = self.Unknowns.DFDt.psi_star[level].sym if self._stress_history == "stress": return stored - c = self._carried_conformation_sym(level) - f = self._fene_f.sym[0] if self._relaxation == "fene_p" else sympy.Integer(1) - return (f * c - sympy.eye(2)) * self.Parameters.shear_modulus + # the record is log(sigma/G + I) for both relaxation laws (for FENE-P that + # is log(f c), not log c), so the stress reads the same way for both + return (self._carried_record_sym(level) - sympy.eye(2)) * self.Parameters.shear_modulus def encode_history(self, stress): r"""What the history stores for a stress: the stress, or - :math:`\log c` with :math:`f(c)\,c = \sigma/G + I` for the - log-conformation history. For FENE-P the trace of that relation, - :math:`f\,\mathrm{tr}\,c = s` with :math:`s = \mathrm{tr}\,\sigma/G + d`, - gives :math:`\mathrm{tr}\,c = s L^2 / (L^2 - d + s)` and hence :math:`f` - in closed form.""" + :math:`\log(\sigma/G + I)` for the log-conformation history. That is + :math:`\log c` for a linear spring and :math:`\log(f c)` for FENE-P, whose + conformation the decode recovers through the trace: + :math:`f = 1 + (\mathrm{tr}\,e^\psi - d)/L^2` (:meth:`_fene_spring_factor_of_record`).""" if self._stress_history == "stress": return stress - fc = sympy.Matrix(stress) / self.Parameters.shear_modulus + sympy.eye(2) - if self._relaxation == "fene_p": - # c = (sigma/G + I)/f with the spring factor of the step, the field - # f* read from the record before the solve: the same first-order lag - # the relaxation rate carries (the exact inverse, through the trace, - # is :meth:`_fene_exact_conformation`; inlined it repeats the whole - # stress expression inside its own trace and the compiled commit - # grows by an order of magnitude) - fc = fc / self._fene_f.sym[0] - return _logm_sym2(fc) + # For FENE-P the record is log(f c), not log c. The conformation itself is + # bounded (tr c < L^2) and the nodal projection that commits the record + # does not respect a bound: at a wall where log c jumps by 4 across one + # cell it overshoots by a factor 1.5 in c, which for a linear spring is a + # 50% ringing of the stress and for a capped spring lands on the + # saturation floor (tr c 105 of L^2 100 from a stress whose own + # conformation had 70: the FENE-P stall on the cylinder). log(f c) is + # unbounded, and every SPD record decodes to an admissible conformation + # through the closed form f = 1 + (tr e^psi - d)/L^2, c = e^psi/f. + return _logm_sym2(sympy.Matrix(stress) / self.Parameters.shear_modulus + sympy.eye(2)) + + def _fene_spring_factor_of_record(self, record): + r"""The spring factor a decoded record :math:`e^\psi = f c` implies, + :math:`f = 1 + (\mathrm{tr}\,e^\psi - d)/L^2`: the trace of + :math:`f c = \sigma/G + I` with :math:`f = (L^2 - d)/(L^2 - \mathrm{tr}\,c)` + eliminates the conformation. Above :math:`(L^2 - d)/L^2` for every SPD + record, so the conformation :math:`e^\psi/f` has :math:`\mathrm{tr}\,c < L^2` + whatever the projection did to :math:`\psi`.""" + d = self.Unknowns.u.mesh.dim + return 1 + (sympy.Matrix(record).trace() - d) / self.Parameters.extensibility + + def _fene_spring_factor_of_stress(self, stress): + r"""The spring factor of a FENE-P stress, :math:`f = 1 + \mathrm{tr}\,\sigma/(G L^2)`: + the trace of :math:`f c = \sigma/G + I` with + :math:`f = (L^2 - d)/(L^2 - \mathrm{tr}\,c)` eliminates the conformation. + + Floored at :math:`(L^2 - d)/L^2`, the factor of a conformation of zero + trace, which is the smallest any FENE-P stress has + (:math:`\mathrm{tr}\,\sigma/G = f\,\mathrm{tr}\,c - d \ge -d`). The stress a + step produces need not be one: the explicit stretching source acting on + a highly extended conformation in a compressive direction can take + :math:`\mathrm{tr}\,\sigma/G` below :math:`-d`, and without the floor + :math:`f` passes through zero and the conformation it implies is + unbounded (seen on the cylinder: the decoded stress rose 400-fold in one + step). Below the floor the stress is not a FENE-P stress; it is encoded + as the nearest one, through the floor on the logarithm.""" + d = self.Unknowns.u.mesh.dim + L2 = self.Parameters.extensibility + f = 1 + sympy.Matrix(stress).trace() / (self.Parameters.shear_modulus * L2) + return sympy.Max(f, (L2 - d) / L2) def _fene_exact_conformation(self, stress): r"""The conformation of a FENE-P stress, exactly: from the trace of :math:`f\,c = \sigma/G + I`, :math:`s = \mathrm{tr}\,\sigma/G + d`, :math:`\mathrm{tr}\,c = s L^2/(L^2 - d + s)` and hence :math:`f`.""" d = self.Unknowns.u.mesh.dim - L2 = self.Parameters.extensibility fc = sympy.Matrix(stress) / self.Parameters.shear_modulus + sympy.eye(d) - tr_c = fc.trace() * L2 / (L2 - d + fc.trace()) - return fc * (L2 - tr_c) / (L2 - d) + return fc / self._fene_spring_factor_of_stress(stress) # The following should have no setters @property @@ -2529,11 +2568,9 @@ def _carried_stress(self): G = np.asarray(_to_nondim_ndarray( uw.function.evaluate(self.Parameters.shear_modulus.sym, points))).reshape(-1) w, v = np.linalg.eigh(tau) - c = v @ (np.exp(w)[:, :, None] * np.transpose(v, (0, 2, 1))) - # the exact f(c) of the record, where the weak form reads the - # lagged nodal field f*: the two differ by the step's change of f - f = self._peterlin_np(c) - tau = G[:, None, None] * (f[:, None, None] * c - np.eye(tau.shape[-1])[None]) + # the record is log(sigma/G + I) for both relaxation laws + fc = v @ (np.exp(w)[:, :, None] * np.transpose(v, (0, 2, 1))) + tau = G[:, None, None] * (fc - np.eye(tau.shape[-1])[None]) return tau, points def max_elastic_timestep(self, safety: float = 0.3) -> float: @@ -2608,11 +2645,15 @@ def conformation_min_eigenvalue(self): dim = tau.shape[-1] from underworld3.systems.ddt import _to_nondim_ndarray if self._stress_history == "log_conformation": - # the conformation itself, from the record (the stress of a FENE-P - # element is G (f c - I), not G (c - I)) + # the conformation itself, from the record: e^psi, divided for FENE-P + # by the spring factor the record implies (its stress is G (f c - I)) psi, _ = self.Unknowns.DFDt.carried_tensors() w, v = np.linalg.eigh(psi) c = v @ (np.exp(w)[:, :, None] * np.transpose(v, (0, 2, 1))) + if self._relaxation == "fene_p": + L2 = float(self.Parameters.extensibility.sym) + f = 1.0 + (np.trace(c, axis1=1, axis2=2) - dim) / L2 + c = c / f[:, None, None] else: G = np.asarray(_to_nondim_ndarray( uw.function.evaluate(self.Parameters.shear_modulus.sym, points))).reshape(-1) diff --git a/tests/test_1068_fene_p.py b/tests/test_1068_fene_p.py index 9de3e126d..044c456d7 100644 --- a/tests/test_1068_fene_p.py +++ b/tests/test_1068_fene_p.py @@ -53,11 +53,12 @@ def shear_box(relaxation, L2, dt, steps, tag): def test_fene_p_steady_shear_converges_to_the_closed_form_at_first_order(): """Wi = 1, L^2 = 10: f = 1.1510 (f^2 (f - 1) = 0.2), tau_xy = 0.8688 eta gdot, - N1 = 1.5097 G. The spring factor is read from the record before the step, so - the scheme's steady state is first order in dt/lambda: measured errors - +0.0065 / +0.0095 at dt = 0.1 and +0.0032 / +0.0052 at dt = 0.05 (an earlier - form of the step kept one 1/f too many on the stretching source and sat - 0.03 / 0.13 off at every dt).""" + N1 = 1.5097 G. The spring factor of the relaxation rate is read from the + record before the step, so the scheme's steady state is first order in + dt/lambda: measured errors +0.0068 / +0.0094 at dt = 0.1 and +0.0034 / +0.0051 + at dt = 0.05 with the record log(f c) (+0.0065 / +0.0095 and +0.0032 / +0.0052 + with the earlier record log c; an earlier form of the step kept one 1/f too + many on the stretching source and sat 0.03 / 0.13 off at every dt).""" xy_ref, n1_ref = steady_shear_fene_p(Wi=1.0, L2=10.0) assert abs(xy_ref - 0.8688) < 1e-3 and abs(n1_ref - 1.5097) < 1e-3 xy1, n11 = shear_box("fene_p", 10.0, 0.1, 80, "fene_a") @@ -162,9 +163,18 @@ def at_point(m): c_exact = cm._fene_exact_conformation(sigma) f_exact = cm._peterlin_sym(c_exact) assert np.abs(at_point(f_exact * c_exact) - (np.array(sigma, dtype=float) / 2.0 + np.eye(2))).max() < 1e-10 - # the history's encoding uses the spring factor of the step as a field: with - # that field holding the exact f, encode then decode gives sigma back - cm._fene_f.data[:, 0] = float(np.asarray(uw.function.evaluate(f_exact, point)).reshape(-1)[0]) - c = _expm_sym2(cm.encode_history(sigma)) - back = (cm._fene_f.sym[0] * c - sympy.eye(2)) * cm.Parameters.shear_modulus - assert np.abs(at_point(back) - np.array(sigma, dtype=float)).max() < 1e-8 + # the record is log(f c) = log(sigma/G + I): its decode is the stress for + # both relaxation laws, and the conformation follows through the trace of + # the record, f = 1 + (tr e^psi - 2)/L^2, c = e^psi/f + record = _expm_sym2(cm.encode_history(sigma)) + assert np.abs(at_point((record - sympy.eye(2)) * cm.Parameters.shear_modulus) - np.array(sigma, dtype=float)).max() < 1e-8 + f_rec = cm._fene_spring_factor_of_record(record) + assert abs(float(np.asarray(uw.function.evaluate(f_rec - f_exact, point)).reshape(-1)[0])) < 1e-10 + assert np.abs(at_point(record / f_rec) - at_point(c_exact)).max() < 1e-10 + # and the conformation is admissible whatever the record: a stress trace of + # 50 L^2 (or a projection overshoot of the same size) decodes to tr c = 9.8 + # of L^2 = 10, never past it + big = sympy.Matrix([[600.0, 0.0], [0.0, 400.0]]) + rb = _expm_sym2(cm.encode_history(big)) + c_big = at_point(rb / cm._fene_spring_factor_of_record(rb)) + assert 9.7 < np.trace(c_big) < 10.0, np.trace(c_big) From 020b02f1dee62fccfc2b08ceb9695be2049a7a9e Mon Sep 17 00:00:00 2001 From: lmoresi Date: Mon, 5 Oct 2026 13:22:01 +1100 Subject: [PATCH 20/21] FENE-P review fixes: L^2 > d guard, field-route test, dead Peterlin path From the review of 31709294: the trace decode f = 1 + (tr e^psi - d)/L^2 is positive for every SPD record only when L^2 > d, so the refresh refuses an extensibility at or below the dimension (test); the nodal field the weak form reads, and the carried strain and stress built from it, are now checked against a record written directly (test); the admissibility assertion pins the closed-form value 502/51 instead of a band; _peterlin_np had no caller and is gone, _fene_spring_factor_of_stress is the record formula applied to sigma/G + I with its floor and its claim about the encode removed; the constructor comment no longer describes the log c record; the diagnostic reads L^2 at the points like G; the docs say which of the f* readers keep the lag and that an old FENE-P snapshot decodes short by f under this record. Underworld development team with AI support from Claude Code Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo --- docs/developer/subsystems/stress-transport.md | 6 +- src/underworld3/constitutive_models.py | 51 ++++++-------- tests/test_1068_fene_p.py | 70 +++++++++++++++++-- 3 files changed, 92 insertions(+), 35 deletions(-) diff --git a/docs/developer/subsystems/stress-transport.md b/docs/developer/subsystems/stress-transport.md index a0fc8851e..d746c2f0a 100644 --- a/docs/developer/subsystems/stress-transport.md +++ b/docs/developer/subsystems/stress-transport.md @@ -269,7 +269,11 @@ a capped spring put the record on the saturation floor (a record trace of 105 with $L^2 = 100$ from a stress whose own conformation had 70; the step then alternated between the saturated and the free spring and the velocity solve stalled). $\log(f c)$ carries no bound, and every SPD record decodes to an -admissible conformation. Only the relaxation rate keeps the lag. Against the closed-form steady +admissible conformation. The stress decode carries no lag; the rate, the +strain source and the deformation term read the pre-solve $f^*$. A FENE-P +snapshot written before this record (2026-10-05) decodes under it with the +stress short by the factor $f$, and nothing in the file tells the two apart. +Against the closed-form steady simple shear ($f^2 (f - 1) = 2\,\mathrm{Wi}^2/L^2$) the scheme is first order in $\Delta t/\lambda$; with infinite extensibility it is Oldroyd-B to 1e-8. It needs the log-conformation history, the exponential integrator at order 1 diff --git a/src/underworld3/constitutive_models.py b/src/underworld3/constitutive_models.py index ecbe83a40..0af7339e8 100644 --- a/src/underworld3/constitutive_models.py +++ b/src/underworld3/constitutive_models.py @@ -1851,8 +1851,9 @@ def __init__(self, unknowns, order=1, integrator: str = "bdf", self._fene_x = uw.discretisation.MeshVariable( f"fene_x_{tag}", unknowns.u.mesh, 1, degree=1, continuous=True, varsymbol=r"{x_{\mathrm{FENE}}}") - # the spring factor f(c*) itself, the same way: the flux reads - # G (f* c* - I) with f* a field rather than a function of the record + # the spring factor f* itself, the same way: the strain source, the + # deformation term and the rate read it as a field rather than as a + # function of the record (the stress needs no f: it is G (e^psi - I)) self._fene_f = uw.discretisation.MeshVariable( f"fene_f_{tag}", unknowns.u.mesh, 1, degree=1, continuous=True, varsymbol=r"{f_{\mathrm{FENE}}}") @@ -2202,9 +2203,16 @@ def _refresh_fene_x(self): r"""Evaluate :math:`f^*` and :math:`\Delta t\,f^*/\lambda` at the nodes of the FENE-P fields from the record as it stands before the solve (explicit in the rate, first order): :math:`f^* = 1 + (\mathrm{tr}\,e^{\psi^*} - d)/L^2`.""" - if self.Parameters.extensibility.sym is sympy.oo: + L2 = self.Parameters.extensibility.sym + if L2 is sympy.oo: raise ValueError("relaxation='fene_p' needs a finite Parameters.extensibility (L^2): " "with it infinite the spring factor is 0/0") + d = self.Unknowns.u.mesh.dim + if L2.is_number and not L2 > d: + # f = 1 + (tr e^psi - d)/L^2 is positive for every SPD record only if + # L^2 > d; below that the decode divides by zero and the rate reverses + raise ValueError(f"relaxation='fene_p' needs Parameters.extensibility (L^2) above the " + f"dimension {d}: a dumbbell at rest has tr c = {d}, got {L2}") lam = self.Parameters.shear_viscosity_0 / self.Parameters.shear_modulus f_sym = self._fene_spring_factor_of_record(self._carried_record_sym(0)) from underworld3.systems.ddt import _to_nondim_ndarray @@ -2293,7 +2301,8 @@ def _check_order_supported(self, order): def _peterlin_sym(self, c): r"""The FENE-P spring factor :math:`f(c) = (L^2 - d)/(L^2 - \mathrm{tr}\,c)` - of a conformation ``c`` (a sympy matrix); one for the Hookean dumbbell.""" + of a conformation ``c`` (a sympy matrix); one for the Hookean dumbbell. + A check on the decode (the solver reads :meth:`_fene_spring_factor_of_record`).""" if self._relaxation != "fene_p": return sympy.Integer(1) d = self.Unknowns.u.mesh.dim @@ -2304,14 +2313,6 @@ def _peterlin_sym(self, c): # is floored at one percent of L^2: f saturates at 100 (L^2 - d)/L^2 return (L2 - d) / sympy.Max(L2 - sympy.Matrix(c).trace(), _FENE_FLOOR * L2) - def _peterlin_np(self, c): - """:meth:`_peterlin_sym` on an array of conformations (n, d, d).""" - if self._relaxation != "fene_p": - return np.ones(c.shape[0]) - d = c.shape[-1] - L2 = float(self.Parameters.extensibility.sym) - return (L2 - d) / np.maximum(L2 - np.trace(c, axis1=1, axis2=2), _FENE_FLOOR * L2) - def _carried_record_sym(self, level=0): r"""The decoded record :math:`e^{\psi^*}` of the log-conformation history at ``level``: the conformation :math:`c^*` of a linear spring, and @@ -2383,23 +2384,12 @@ def _fene_spring_factor_of_record(self, record): def _fene_spring_factor_of_stress(self, stress): r"""The spring factor of a FENE-P stress, :math:`f = 1 + \mathrm{tr}\,\sigma/(G L^2)`: - the trace of :math:`f c = \sigma/G + I` with - :math:`f = (L^2 - d)/(L^2 - \mathrm{tr}\,c)` eliminates the conformation. - - Floored at :math:`(L^2 - d)/L^2`, the factor of a conformation of zero - trace, which is the smallest any FENE-P stress has - (:math:`\mathrm{tr}\,\sigma/G = f\,\mathrm{tr}\,c - d \ge -d`). The stress a - step produces need not be one: the explicit stretching source acting on - a highly extended conformation in a compressive direction can take - :math:`\mathrm{tr}\,\sigma/G` below :math:`-d`, and without the floor - :math:`f` passes through zero and the conformation it implies is - unbounded (seen on the cylinder: the decoded stress rose 400-fold in one - step). Below the floor the stress is not a FENE-P stress; it is encoded - as the nearest one, through the floor on the logarithm.""" - d = self.Unknowns.u.mesh.dim - L2 = self.Parameters.extensibility - f = 1 + sympy.Matrix(stress).trace() / (self.Parameters.shear_modulus * L2) - return sympy.Max(f, (L2 - d) / L2) + :meth:`_fene_spring_factor_of_record` of :math:`\sigma/G + I`. Exact for a + stress whose :math:`\sigma/G + I` is SPD (the decode of every record is one); + for any other matrix the record's logarithm floors the eigenvalues first + and the two differ.""" + return self._fene_spring_factor_of_record( + sympy.Matrix(stress) / self.Parameters.shear_modulus + sympy.eye(self.Unknowns.u.mesh.dim)) def _fene_exact_conformation(self, stress): r"""The conformation of a FENE-P stress, exactly: from the trace of @@ -2651,7 +2641,8 @@ def conformation_min_eigenvalue(self): w, v = np.linalg.eigh(psi) c = v @ (np.exp(w)[:, :, None] * np.transpose(v, (0, 2, 1))) if self._relaxation == "fene_p": - L2 = float(self.Parameters.extensibility.sym) + L2 = np.asarray(_to_nondim_ndarray( + uw.function.evaluate(self.Parameters.extensibility.sym, points))).reshape(-1) f = 1.0 + (np.trace(c, axis1=1, axis2=2) - dim) / L2 c = c / f[:, None, None] else: diff --git a/tests/test_1068_fene_p.py b/tests/test_1068_fene_p.py index 044c456d7..d805f0066 100644 --- a/tests/test_1068_fene_p.py +++ b/tests/test_1068_fene_p.py @@ -111,6 +111,68 @@ def test_the_element_is_what_was_declared_and_a_maxwell_element_refuses_a_parall stokes.solve(timestep=0.1) +def test_the_spring_factor_field_the_solver_reads_is_the_record_s(): + """After the pre-solve refresh the nodal field f* equals 1 + (tr e^psi - d)/L^2 + of the record at every node, and the carried strain the objective rate acts + on is G (c* - I) with c* = e^psi/f*: the field route into the weak form, not + the test-side algebra. The record is written directly (log of a chosen + f c), at a trace far from one so the factor is not trivially 1.""" + mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.5, qdegree=3) + v = uw.discretisation.MeshVariable("U_fene_fld", mesh, 2, degree=2) + p = uw.discretisation.MeshVariable("P_fene_fld", mesh, 1, degree=1) + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + stokes.stress_transport = "backward_nodes" + stokes.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + stokes.Unknowns, order=1, integrator="etd", objective_rate="upper_convected", + stress_history="log_conformation", relaxation="fene_p", element="jeffreys") + cm = stokes.constitutive_model + cm.Parameters.shear_viscosity_0 = 1.0 + cm.Parameters.shear_modulus = 2.0 + cm.Parameters.dt_elastic = 0.1 + cm.Parameters.extensibility = 10.0 + stokes.add_dirichlet_bc((0.0, 0.0), "Bottom") + stokes.add_dirichlet_bc((0.0, 0.0), "Top") + stokes.solve(timestep=0.1) # creates and commits the record once + record = cm.Unknowns.DFDt.psi_star[0] + fc = np.array([[6.0, 1.0], [1.0, 3.0]]) # tr 9 -> f = 1 + 7/10 = 1.7 + w, q = np.linalg.eigh(fc) + psi = q @ np.diag(np.log(w)) @ q.T + assert record.data.shape[1] == 3 # diagonal first, then xy (test_0066 pins the order) + record.data[:, :] = [psi[0, 0], psi[1, 1], psi[0, 1]] + cm._refresh_fene_x() + assert np.allclose(cm._fene_f.data, 1.7, atol=1e-10), cm._fene_f.data.min() + assert np.allclose(cm._fene_x.data, 0.1 * 1.7 / 0.5, atol=1e-10) # dt f/lambda, lambda = 0.5 + point = np.array([[0.5, 0.5]]) + strain = cm._carried_strain_sym(0) + got = np.array([[float(np.asarray(uw.function.evaluate(strain[i, j], point)).reshape(-1)[0]) for j in range(2)] + for i in range(2)]) + assert np.abs(got - 2.0 * (fc / 1.7 - np.eye(2))).max() < 1e-8, got + stress = cm._carried_stress_sym(0) + got_s = np.array([[float(np.asarray(uw.function.evaluate(stress[i, j], point)).reshape(-1)[0]) for j in range(2)] + for i in range(2)]) + assert np.abs(got_s - 2.0 * (fc - np.eye(2))).max() < 1e-8, got_s + + +def test_fene_p_refuses_an_extensibility_at_or_below_the_dimension(): + mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.5, qdegree=3) + v = uw.discretisation.MeshVariable("U_fene_small", mesh, 2, degree=2) + p = uw.discretisation.MeshVariable("P_fene_small", mesh, 1, degree=1) + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + stokes.stress_transport = "backward_nodes" + stokes.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + stokes.Unknowns, order=1, integrator="etd", objective_rate="upper_convected", + stress_history="log_conformation", relaxation="fene_p", element="jeffreys") + cm = stokes.constitutive_model + cm.Parameters.shear_viscosity_0 = 1.0 + cm.Parameters.shear_modulus = 1.0 + cm.Parameters.dt_elastic = 0.1 + cm.Parameters.extensibility = 2.0 + stokes.add_dirichlet_bc((0.0, 0.0), "Bottom") + stokes.add_dirichlet_bc((0.0, 0.0), "Top") + with pytest.raises(ValueError, match="above the dimension"): + stokes.solve(timestep=0.1) + + def test_fene_p_refuses_an_infinite_extensibility(): mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(-1.0, -0.5), maxCoords=(1.0, 0.5), cellSize=0.5, qdegree=3) v = uw.discretisation.MeshVariable("U_inf", mesh, 2, degree=2) @@ -171,10 +233,10 @@ def at_point(m): f_rec = cm._fene_spring_factor_of_record(record) assert abs(float(np.asarray(uw.function.evaluate(f_rec - f_exact, point)).reshape(-1)[0])) < 1e-10 assert np.abs(at_point(record / f_rec) - at_point(c_exact)).max() < 1e-10 - # and the conformation is admissible whatever the record: a stress trace of - # 50 L^2 (or a projection overshoot of the same size) decodes to tr c = 9.8 - # of L^2 = 10, never past it + # and the conformation is admissible whatever the record: tr sigma/G of + # 50 L^2 (or a projection overshoot of the same size) decodes to + # tr c = 502/51 = 9.843 of L^2 = 10, never past it big = sympy.Matrix([[600.0, 0.0], [0.0, 400.0]]) rb = _expm_sym2(cm.encode_history(big)) c_big = at_point(rb / cm._fene_spring_factor_of_record(rb)) - assert 9.7 < np.trace(c_big) < 10.0, np.trace(c_big) + assert abs(np.trace(c_big) - 502.0 / 51.0) < 1e-8, np.trace(c_big) From dcd363c048d08e15ef0c8827dd493a83f868ed99 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Thu, 8 Oct 2026 15:14:01 +1100 Subject: [PATCH 21/21] Merge development: the renamed schemes reach the contracts and the docs The rename lands in four places the branch had not reached, and one anchor was stale. `SemiLagrangian` is a factory over (trace, launch), not a class, so it carried no class-level description -- and `tests/test_0017_describe_and_render.py` requires every family to answer at that level, while `docs/developer/subsystems/describe-and-view.md` calls `SemiLagrangian.describe_class()` directly. The factory now delegates `describe_class` and `view` to the default scheme, which is what it builds when neither axis is given, and exposes `schemes` for a caller that wants another. Three expectations follow the rename: the describe contract and the step transcript now name `BackwardNodesSemiLagrangian`, and the transport-schemes guide's front matter lists the four trace-and-launch families instead of two names no class carries (`tests/test_0030_capability_guides.py`). `docs/advanced/eulerian-advection-diffusion.md` described three managers; the section now says how the two axes select a scheme, and records that a forward trace's per-cell fit is unstable where the flow empties a cell (#811). The eulerian cavity anchor is re-recorded. It moved 4e-05 relative on merging development, and the new value is development's own: the same cavity with the default EulerianSUPG history gives [-0.129019, -0.05467728] there, before and after #833, unchanged to nine digits from tolerance 1e-8 through 1e-12. The original was recorded 2026-09-29, before this branch last merged development. That leaves something worth knowing: the Eulerian SUPG velocity answer moved on development and nothing there anchors it. This file is the only test that can see it. level_1 tier_a: 1428 passed, and the one failure it had (the capability guide) is fixed here. test_1103: 7 passed. Underworld development team with AI support from Claude Code --- docs/advanced/eulerian-advection-diffusion.md | 38 ++++++++++++++----- docs/developer/guides/transport-schemes.md | 4 +- src/underworld3/systems/ddt.py | 14 +++++++ tests/test_0011_model_step_transcript.py | 4 +- tests/test_0017_describe_and_render.py | 11 +++++- tests/test_0030_capability_guides.py | 4 +- ...t_1103_navier_stokes_velocity_transport.py | 18 ++++++++- 7 files changed, 76 insertions(+), 17 deletions(-) diff --git a/docs/advanced/eulerian-advection-diffusion.md b/docs/advanced/eulerian-advection-diffusion.md index a4556d639..e687ba0f9 100644 --- a/docs/advanced/eulerian-advection-diffusion.md +++ b/docs/advanced/eulerian-advection-diffusion.md @@ -80,11 +80,18 @@ is resolved in time (a fraction of a feature width per step), the Eulerian solve is cheaper and more accurate; if the step is deliberately long relative to the transported features, the semi-Lagrangian solver is the one that survives it. -## The three transport managers +## The transport managers -`DuDt` selects the transport, and three managers are worth considering for a -scalar field. The choice turns on the Courant number the model runs at and on -whether the model is carrying particles for another reason. +`DuDt` selects the transport. The choice turns on the Courant number the model +runs at and on whether the model is carrying particles for another reason. + +The semi-Lagrangian schemes are named by two choices, and +`uw.systems.ddt.SemiLagrangian(..., trace=, launch=)` builds any of them: +`trace` is `"backward"` (follow the characteristic back from each storage point +and sample the old field at the departure point) or `"forward"` (launch the old +field from where it is known, carry it one step, and fit the arrivals in each +cell); `launch` is `"nodes"` or `"integration_points"`. Called with neither, it +builds `BackwardNodesSemiLagrangian`, the historical default. **Eulerian SUPG** (`uw.systems.ddt.EulerianSUPG`, the default) is the general choice. Its error falls as $\Delta t^2$, it puts no lower limit on the Courant @@ -95,7 +102,9 @@ enclosed volume to 4e-5 against that scheme's 5e-3. Use it unless something below applies. **Semi-Lagrangian on the integration points** -(`uw.systems.ddt.IntegrationPointSemiLagrangian`) is the accurate choice at +(`uw.systems.ddt.BackwardIntegrationPointsSemiLagrangian`, or +`SemiLagrangian(trace="backward", launch="integration_points")`) is the +accurate choice at larger Courant numbers. Its error is flat between Courant 0.5 and 2, so a model that takes long steps keeps its accuracy where the Eulerian scheme loses it, and it loses 45 times less of the second moment than the nodal scheme does. It @@ -118,11 +127,20 @@ aimed at models that already carry a swarm for material tracking, where the transport rides on particles the model is advecting anyway. We would not expect to introduce particles in order to use it. -**Semi-Lagrangian at the nodes** (`uw.systems.ddt.SemiLagrangian`) remains the -historical default of `AdvDiffusionSLCN`. It re-interpolates once per step, which -costs it accuracy at small Courant numbers, and on a deforming flow with a sharp -interface it diverges below a Courant number that depends on the problem. Prefer -one of the three above. +**Semi-Lagrangian at the nodes** +(`uw.systems.ddt.BackwardNodesSemiLagrangian`) remains the historical default of +`AdvDiffusionSLCN`. It re-interpolates once per step, which costs it accuracy at +small Courant numbers, and on a deforming flow with a sharp interface it +diverges below a Courant number that depends on the problem. Prefer one of the +three above. + +**Forward traces** (`ForwardIntegrationPointsSemiLagrangian`, +`ForwardNodesSemiLagrangian`) carry the field from where it is known rather +than sampling where it is wanted, which is what a stress history needs when the +departure point falls outside the domain. They are newer than the measurements +above and are documented with the stress transport, in +`docs/developer/subsystems/stress-transport.md`. The per-cell fit a forward +trace performs is unstable where the flow empties a cell (#811). ## Choosing the time scheme diff --git a/docs/developer/guides/transport-schemes.md b/docs/developer/guides/transport-schemes.md index d6fa772ba..aaa60ddcd 100644 --- a/docs/developer/guides/transport-schemes.md +++ b/docs/developer/guides/transport-schemes.md @@ -1,7 +1,9 @@ --- name: transport-schemes description: Which transport scheme and which time history to use in Underworld3, and why — nodal, integration-point or grid histories; semi-Lagrangian, Eulerian SUPG or Lagrangian swarm transport; the Courant number to run at; what each choice does to a settled state and to a peak. The evidence is the tests and notes named beside each ruling. -families: [AdvDiffusion, NavierStokes, Eulerian, EulerianSUPG, SemiLagrangian, Lagrangian, Lagrangian_Swarm, IntegrationPointSemiLagrangian] +families: [AdvDiffusion, NavierStokes, Eulerian, EulerianSUPG, Lagrangian, Lagrangian_Swarm, + BackwardNodesSemiLagrangian, BackwardIntegrationPointsSemiLagrangian, + ForwardIntegrationPointsSemiLagrangian, ForwardNodesSemiLagrangian] kind: guide status: draft, rulings to be confirmed --- diff --git a/src/underworld3/systems/ddt.py b/src/underworld3/systems/ddt.py index 246ee9c7d..c7e74df98 100644 --- a/src/underworld3/systems/ddt.py +++ b/src/underworld3/systems/ddt.py @@ -6538,6 +6538,20 @@ def SemiLagrangian(mesh, psi_fn, V_fn, vtype=VarType.SCALAR, *, trace="backward" return scheme(mesh, psi_fn, V_fn, vtype, **kwargs) +# `SemiLagrangian` is a factory, not a class, so it does not inherit the +# class-level description API that every other family carries. Code and docs +# written against the old class still call `SemiLagrangian.describe_class()` +# and `.view()` -- `docs/advanced/semi-lagrangian-time-integration.md` among +# them -- and `tests/test_0017_describe_and_render.py` requires every family to +# answer at the class level. Both delegate to the default scheme, which is what +# `SemiLagrangian(...)` with no `trace`/`launch` builds. +SemiLagrangian.describe_class = _SEMI_LAGRANGIAN_SCHEMES[("backward", "nodes")].describe_class +SemiLagrangian.view = _SEMI_LAGRANGIAN_SCHEMES[("backward", "nodes")].view +#: The schemes the factory can build, for a caller that wants to describe one +#: it is not asking for. +SemiLagrangian.schemes = dict(_SEMI_LAGRANGIAN_SCHEMES) + + _RENAMED = { "IntegrationPointSemiLagrangian": "BackwardIntegrationPointsSemiLagrangian", "ForwardSemiLagrangian": "ForwardIntegrationPointsSemiLagrangian", diff --git a/tests/test_0011_model_step_transcript.py b/tests/test_0011_model_step_transcript.py index 2bfd76e1f..68099bffc 100644 --- a/tests/test_0011_model_step_transcript.py +++ b/tests/test_0011_model_step_transcript.py @@ -349,8 +349,10 @@ def test_a_field_history_and_its_flux_history_are_named_apart(): solver.solve(timestep=0.01) shifts = [e for e in model.transcript[0].events if e["kind"] == "history_shift"] + # The scheme names itself by trace and launch: `SemiLagrangian` is the + # factory, `BackwardNodesSemiLagrangian` the scheme it builds by default. assert [e["name"] for e in shifts] == [ - "SemiLagrangian(T_two)", "SemiLagrangian(F[T_two])" + "BackwardNodesSemiLagrangian(T_two)", "BackwardNodesSemiLagrangian(F[T_two])" ], [e["name"] for e in shifts] assert shifts[0]["part"] != shifts[1]["part"] # what each holds is in the record, exactly diff --git a/tests/test_0017_describe_and_render.py b/tests/test_0017_describe_and_render.py index 8df51077f..31dc0a7d4 100644 --- a/tests/test_0017_describe_and_render.py +++ b/tests/test_0017_describe_and_render.py @@ -157,8 +157,15 @@ def test_every_family_describes_itself_at_the_class_level(capsys): d = cls.describe_class() assert d["kind"] == "constitutive_model_family" and {t["name"] for t in d["terms"]} assert "yield_stress" in {t["name"] for t in uw.constitutive_models.ViscoPlasticFlowModel.describe_class()["terms"]} + # `SemiLagrangian` is a factory over (trace, launch); its class-level + # description is the default scheme's, which is what it builds when neither + # is given. The scheme reports its own name, not the factory's. d = uw.systems.ddt.SemiLagrangian.describe_class() - assert d["kind"] == "history_family" and d["facts"]["scheme"] == "SemiLagrangian" + assert d["kind"] == "history_family" + assert d["facts"]["scheme"] == "BackwardNodesSemiLagrangian" + assert (uw.systems.ddt.SemiLagrangian.describe_class() + is not uw.systems.ddt.ForwardNodesSemiLagrangian.describe_class), \ + "the factory must not shadow another scheme's description" def test_the_capabilities_catalogue_is_the_families_in_one_record(capsys): @@ -171,6 +178,6 @@ def test_the_capabilities_catalogue_is_the_families_in_one_record(capsys): full = uw.capabilities("solvers", detail="full") assert full["children"][0]["children"][0].get("documentation") uw.view(uw.capabilities("histories")) - assert "SemiLagrangian" in capsys.readouterr().out + assert "BackwardNodesSemiLagrangian" in capsys.readouterr().out with pytest.raises(ValueError): uw.capabilities("nothing") diff --git a/tests/test_0030_capability_guides.py b/tests/test_0030_capability_guides.py index b727c9ff3..e08f4e8f5 100644 --- a/tests/test_0030_capability_guides.py +++ b/tests/test_0030_capability_guides.py @@ -52,7 +52,9 @@ def test_every_guide_names_real_families(): def test_families_list_their_guides(): assert "boundary-condition-rulings" in guides_for("Stokes") - assert "transport-schemes" in guides_for("SemiLagrangian") + # The scheme names itself by trace and launch; `SemiLagrangian` is the + # factory over those two axes and carries no family name of its own. + assert "transport-schemes" in guides_for("BackwardNodesSemiLagrangian") stokes = uw.systems.Stokes.describe_class() assert "nonlinear-solver" in stokes["facts"]["guides"] cat = uw.capabilities("solvers") diff --git a/tests/test_1103_navier_stokes_velocity_transport.py b/tests/test_1103_navier_stokes_velocity_transport.py index fefbcdadf..0c95b321b 100644 --- a/tests/test_1103_navier_stokes_velocity_transport.py +++ b/tests/test_1103_navier_stokes_velocity_transport.py @@ -17,9 +17,23 @@ pytestmark = [pytest.mark.level_2, pytest.mark.tier_b] POINTS = np.array([[0.5, 0.75], [0.3, 0.5]]) -# BASELINES: horizontal velocity at POINTS after ten steps (2026-09-29) +# BASELINES: horizontal velocity at POINTS after ten steps (2026-09-29; +# "eulerian" re-recorded 2026-10-08 against development). +# +# The eulerian row moved by 5.1e-06 and 1.3e-06 -- 4e-05 relative -- when this +# branch merged development. It is development's answer, not this branch's: +# the same cavity built with the default EulerianSUPG history on development +# gives [-0.129019, -0.05467728] exactly, both before and after #833, and the +# value is unchanged to nine digits from solver tolerance 1e-8 through 1e-12, +# so it is converged and deterministic rather than a tolerance artefact. The +# original row was recorded on this branch before it had merged development +# since 2026-09-29. +# +# Worth stating because nothing on development anchors this quantity: the +# Eulerian SUPG velocity history's answer moved there and no test saw it. This +# file is the only one that can, which is the argument for landing it. CAVITY_U = { - "eulerian": (-0.1290241, -0.0546760), + "eulerian": (-0.1290190, -0.0546773), "backward_nodes": (-0.1296780, -0.0552098), "backward_integration_points": (-0.1288954, -0.0548034), "forward_nodes": (-0.1295085, -0.0550723),