diff --git a/.gitignore b/.gitignore index 461c280..1a0f02b 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,8 @@ articles/*/*.pdf # macOS .DS_Store + +# Underworld writes a mesh cache beside the script that asked for a mesh. It is +# regenerated by running the example, and one note's examples committed 650 kB +# of it before this line existed. +.meshes/ diff --git a/WRITING-PLAN.md b/WRITING-PLAN.md index f23c95f..1c32c3b 100644 --- a/WRITING-PLAN.md +++ b/WRITING-PLAN.md @@ -220,9 +220,9 @@ beginning of a solvers paper, but neither is being written to fit one. ### R1. Boundary conditions on non-planar boundaries -Status: not started. **One post**, not a series. Rescoped 2026-08-17: rotated -boundary conditions are the answer, but the *question* is the better frame, and -it is the one a reader arrives with. +Status: drafted as UWTN 2026-016, in review on PR #26. **One post**, not a +series. Rescoped 2026-08-17: rotated boundary conditions are the answer, but the +*question* is the better frame, and it is the one a reader arrives with. On a box, "no flow through this wall" is a component of the velocity and you constrain it. On an annulus, a sphere, a boundary with topography, or any mesh @@ -451,6 +451,6 @@ Listed as candidates, not commitments. | 6 | F5 The comparison | Last, with Thyagarajulu's benchmarks as its evidence | | 7 | S1, S2 free surface | After the discussion about how to split it | | — | C1 Launching from any repository | Standalone; the capability is live and undocumented | -| — | R1 BCs on non-planar boundaries | Standalone; write whenever it suits. Much of the evidence exists — see the rescoped entry | +| — | R1 BCs on non-planar boundaries | Drafted as UWTN 2026-016, in review on PR #26 | | ✓ | G1 Setting up FMG | Published 2026-08-17 (UWTN 2026-014) | | 8 | #1 Release announcement | Written last; links to everything | diff --git a/articles/boundary-conditions-on-non-planar-boundaries/boundary-conditions-on-non-planar-boundaries.md b/articles/boundary-conditions-on-non-planar-boundaries/boundary-conditions-on-non-planar-boundaries.md new file mode 100644 index 0000000..b2d79aa --- /dev/null +++ b/articles/boundary-conditions-on-non-planar-boundaries/boundary-conditions-on-non-planar-boundaries.md @@ -0,0 +1,680 @@ +--- +title: Free-Slip boundary conditions on curved boundaries +description: >- + "No flow through this wall" is a single velocity component on a box and is + not a component of anything on a sphere, a deformed mesh, or a surface with + topography. Three ways to impose it — a direct penalty, Nitsche, and rotating + the degrees of freedom — what each costs, and the one measurement that tells + them apart. +date: 2026-08-18 +authors: + - name: Louis Moresi + orcid: 0000-0003-3685-174X + affiliations: + - Australian National University +license: CC-BY-4.0 +keywords: + - Underworld Code + - Tricks of the Trade + - development +exports: + - format: typst + logo: ../../static/uwtn-logo.png + series: "Underworld Technical Notes" + origin_url: https://www.underworldcode.org/boundary-conditions-on-non-planar-boundaries/ + template: ../../templates/pdf + output: boundary-conditions-on-non-planar-boundaries.pdf + article_id: UWTN 2026-016 + article_version: 1.0.0 + software_version: underworld3 development @ 8b7c8b9e +--- +Free-slip boundary conditions are used to simplify the physical behaviour at a domain boundary. +It may be a free-surface where the boundary deforms slightly in response to the internal flow, or it may +be an interface where the boundary layer thickness is so small that it cannot be resolved at the same time +time as the interior flow. The simplifying assumption: ignore the changes in shape, ignore the thin boundary layer, +treat the surface as impenetrable, and the tangential stresses as vanishingly small. + +$$ +\mathbf{u}\cdot\hat{\mathbf{n}} = 0 +\qquad\text{and}\qquad +\hat{\mathbf{t}}\cdot\boldsymbol{\sigma}\cdot\hat{\mathbf{n}} = 0. +$$ (eq-free-slip) + +In the weak form, boundary tractions appear as surface integrals. Multiplying the momentum +balance by a test function $\mathbf{w}$ and integrating by parts gives + +$$ +\int_\Omega \boldsymbol{\sigma} : \nabla\mathbf{w} \; \mathrm{d}V +- \int_{\partial\Omega} (\boldsymbol{\sigma}\cdot\hat{\mathbf{n}})\cdot\mathbf{w} + \; \mathrm{d}S + = \int_\Omega \mathbf{f}\cdot\mathbf{w} \; \mathrm{d}V . +$$ + +Drop the surface integral and you have imposed zero traction in all +directions — free *everything* (a free surface), not free slip. Constrain the surface-normal degrees of freedom +and the surface integral only addresses the tangential traction terms. + +On a Cartesian box, the first expression in {eq}`eq-free-slip` constrains a single velocity component. If you hold $u_x$ fixed on a vertical wall, the solver removes a row of unknowns, and there is nothing further to +discuss. On a sphere, an annulus, a mesh that does not align with the coordinates, or a surface with +topography, $\mathbf{u}\cdot\hat{\mathbf{n}}$ is not a single component of the unknown — it constrains a combination of unknowns at +each point, and leaves other combinations free. That has the potential to make a simplifying +assumption complicated to implement. + +Let's assume, for a moment, we confine ourselves to simple domains such as an annulus, or a spherical shell, +which are commonly used for planetary modelling. For each of these cases, there are +coordinate systems, and well known forms of the differential operators that do restore the boundary condition +to being a constraint in a single direction. Admittedly this requires reformulating all the equations, but for +a symbolic-first code such as underworld, this is quite straightforward. +This is the strategy used by CITCOMS [Zhong et al, 2008](https://doi.org/10.1029/2008GC002048). But not every domain boundary has a convenient +coordinate system to follow. Even accounting for slight ellipticity introduces + significant complexity in all the differential +operators; anything more complicated will not have a useful +coordinate reformulation. + +The second condition in {eq}`eq-free-slip` is also worth noting. +We don't generally think about this when we constrain a degree of freedom, +the other one/s, left unconstrained are *natural* to the problem. +They fall out as traction-free surface conditions +automatically in a finite element weak form. +If we cannot simply eliminate one degree of freedom at each point, +how **do** we satisfy all the parts of {eq}`eq-free-slip` ? + +## Four possibilities + +We outline four possible approaches (all of which you can try out in Underworld3). +They fall into two pairs: two +impose the constraint **weakly**, by adding a term to the momentum equation and +letting the solution satisfy the condition to within the accuracy of that term: a +direct penalty, and Nitsche's method, which differ in whether the term is +consistent. Two impose it **exactly**: by construction, changing the basis so +that the constraint is a component that can be struck out, or by a Lagrange +multiplier, adding an equation that enforces it. The weak pair have a parameter +to select that may need to be tuned for each problem and a floor + they cannot go below. The exact pair are not tuneable, and they both +return the boundary traction as a side-effect of the solution. + +### 1. A direct penalty + +This could not be more simple, conceptually. We are working in a variational +environment, so we just add into our equation system, a term that punishes any flow through the boundary: + +$$ +\dots + \kappa\int_{\partial\Omega} +(\mathbf{u}\cdot\hat{\mathbf{n}})(\mathbf{w}\cdot\hat{\mathbf{n}}) +\; \mathrm{d}S . +$$ + +$\kappa$ is a single scalar. It has to absorb the scale of the problem itself, which is why +the value that works is a property of the model rather than a default. + +One line, no new machinery, and it works on any geometry. +What we are solving is a mildly perturbed problem and it is perturbed by +exactly the amount the constraint cannot be satisfied: the discrete solution +sits where the penalty term balances the boundary +traction and this leaves $\mathbf{u}\cdot\hat{\mathbf{n}}$ small but not zero. + +Making the residual $\mathbf{u}\cdot\hat{\mathbf{n}}$ smaller means pushing harder, and pushing harder +degrades the condition-number of the operator. The error is traded against the conditioning, +and (discussed below), this trade-off eventually stops returning any benefit. + +Underworld codes this term it as a boundary traction opposing normal flow, using the +surface normal at the quadrature points ($\Gamma$) provided by PETSc: + +```python +G = mesh.Gamma +penalty = 10000 +stokes.add_natural_bc(penalty * G.dot(v.sym) * G, "Upper") +``` + +### Nitsche's method + +The reason the penalty is only accurate in the limit is that it is not +*consistent*: substituting the true solution does not fully satisfy the equation, +because the true solution is subject to a separate boundary traction the +penalty form ignores. Nitsche's method [@Nitsche_1971] restores +consistency by carrying that traction explicitly: + +$$ +\dots +- \int_{\partial\Omega} (\hat{\mathbf{n}}\cdot\boldsymbol{\sigma}(\mathbf{u}) + \cdot \hat{\mathbf{n}})(\mathbf{w}\cdot\hat{\mathbf{n}}) \; \mathrm{d}S +- \int_{\partial\Omega} (\hat{\mathbf{n}}\cdot\boldsymbol{\sigma}(\mathbf{w}) + \cdot \hat{\mathbf{n}})(\mathbf{u}\cdot\hat{\mathbf{n}}) \; \mathrm{d}S ++ \frac{\gamma}{h}\int_{\partial\Omega} + (\mathbf{u}\cdot\hat{\mathbf{n}})(\mathbf{w}\cdot\hat{\mathbf{n}}) + \; \mathrm{d}S . +$$ + +The first of the three is the consistency term: it is the boundary traction the +integration by parts produced, and including this makes the true +solution satisfy the discrete equations exactly. The second is its transpose, +which keeps the form symmetric and buys optimal convergence in $L^2$. The third +is the penalty again, and it is still needed — but now for *stability* rather +than for accuracy, and $\gamma$ has a threshold set by an inverse inequality +rather than being an unspecified free parameter. + +This is a real improvement and it is still done through a weak imposition. The constraint +holds to the accuracy of the discretisation, not to the accuracy of the +arithmetic — measured below, it leaks a few parts in a thousand on a typical mesh, + and the leak falls with increasing mesh resolution. + +**The topography** comes the same way as the penalty's, and for the same reason: +the consistency term supplies the traction inside the momentum row, so there is +no reaction left in the residual to read. Recover $\sigma_{nn}$ from the solved +fields and divide by $\Delta\rho\,g$. + +### A constraint equation, with a multiplier + +The two strategies above add a *term* to the weak form of the equation. +This approach adds an *equation*. + +Carry a scalar field $\lambda$ on the boundary and require, as a row of the +system in its own right, + +$$ +\int_{\partial\Omega} (\mathbf{u}\cdot\hat{\mathbf{n}} - \tilde{u}_n)\, q +\; \mathrm{d}S = 0 \quad \text{for all } q , +$$ + +where $\tilde{u}_n$ is the prescribed wall-normal velocity — zero for free slip, +and a datum if the wall is being driven — and $\lambda$ enters the momentum row +as the traction $\lambda\hat{\mathbf{n}}$ that holds the constraint. It is a +Lagrange multiplier, and the system becomes a larger saddle point: velocity, +pressure, and now $\lambda$. + +$\lambda$ has units of stress. At convergence it *is* $\sigma_{nn}$ on that +boundary, so dividing by $\Delta\rho\,g$ (density contrast $\times$ gravity) +is the dynamic topography. + +The constraint row is exact, so unlike a penalty there is no parameter whose +size decides how well it holds. Two practical things do have to be dealt with. + +- $\lambda$ is carried as a full-domain field but only its boundary trace means + anything, so the interior degrees of freedom are constrained out of the global + system in the section before the solve sees it. They are not solved for and + are not stored in the $[p, \lambda]$ block. +- The $[p, \lambda]$ Schur complement is poorly conditioned on its own, so an + augmented-Lagrangian term + $r(\mathbf{u}\cdot\hat{\mathbf{n}} - \tilde{u}_n)\,\hat{\mathbf{n}}$ is added to the momentum row. It does not change what the constraint enforces, + because the $\lambda$ row still carries the exact constraint. + +```python +stokes = uw.systems.Stokes_Constrained(mesh, velocityField=v, pressureField=p) +lam = stokes.add_constraint_bc(0.0, "Upper") +stokes.solve() +``` + +**Note**: **at convergence, the +momentum row's boundary term is the normal traction.** It does not need to be recovered from +the velocity field after the fact. It is an unknown the solve returns, available (on the solver) +through `traction` and, divided by $\Delta\rho g$, through `topography`. + +That term is the whole boundary load, +$\lambda + r(\mathbf{u}\cdot\hat{\mathbf{n}} - \tilde{u}_n)$, not the +multiplier alone. The second part vanishes only where the constraint row is +satisfied exactly; discretely it is satisfied to the solver's tolerance, and $r$ +multiplies that residual back into the traction. With a viscosity-weighted $r$ +and a lateral viscosity contrast it can be the largest term of the result, +so the two parts are not separable in practice. + +### Rotating the degrees of freedom + +In this approach, we stop *"asking for"* the constraint and just impose it. +At each constrained node, we change +the coordinate basis in which the velocity unknowns are expressed, from the global +Cartesian frame to the local $(\hat{\mathbf{n}}, \hat{\mathbf{t}})$ frame. In +that basis "no flow through the boundary" is again a single component, and it +is removed the same way it would be on a box. + +Collect the per-node rotations into a block-diagonal $Q$, equal to the identity +at every node that is not constrained. The rotated system is + +$$ +\hat{A} = Q^{T} A Q, \qquad \hat{\mathbf{b}} = Q^{T}\mathbf{b}, +\qquad \mathbf{u} = Q\hat{\mathbf{u}} , +$$ + +and the wall-normal row of $\hat{A}$ is struck out. The constraint then holds to +machine precision, because it is not being solved for at all. + +**This is the classical strategy**. It is in the early +finite-element literature, and Engelman, Sani and Gresho [@Engelman_1982] +were already reviewing +the alternatives and choosing between them on grounds of global mass +conservation in 1982. What is worth explaining is not the idea but why, given +that it is exact and the others are not, it is the least used of the three. + +**The topography** is the reaction of that struck row — the force the constraint +had to supply — de-smeared by the boundary mass to turn an integrated nodal load +into a pointwise stress, + +$$ +\sigma_{nn} = -M_\Gamma^{-1}\left.(A\mathbf{u} - \mathbf{b})\right|_\Gamma, +\qquad +h = -\frac{\sigma_{nn} - \overline{\sigma_{nn}}}{\Delta\rho\,g} , +$$ + +which is the consistent boundary flux of Zhong, Gurnis and Hulbert +[@Zhong_1993]. In Underworld3, the solver's `boundary_normal_traction()` and `dynamic_topography()` return +these. Nothing is differentiated and nothing is solved: in two dimensions +$M_\Gamma$ is lumped and the de-smear is a division. + +## Trade-offs + +Rotating the degrees of freedom leaves the discrete problem in a **mixed +basis**. Interior nodes hold $(u_x, u_y)$; constrained nodes hold +$(u_n, u_t)$. Nothing about that is difficult in itself, but everything +downstream has to agree about which nodes are which. + +```{figure} figures/rotated-basis.svg +:alt: Two panels. On the left, a meshed domain bounded above by a free surface that rises on the left and falls on the right with an inflection between, so that the outward normal points in a different direction at every surface node. Surface nodes are drawn as filled circles each carrying its own rotated pair of arrows labelled n and t; interior nodes are open circles, with one carrying the unrotated x and y arrows shared by all of them. On the right, a block diagram. A red block labelled "Velocity solve, rotated" contains the rotated operator and right-hand side, and encloses a smaller block labelled "Multigrid" listing three rows: prolongation becomes Q-transpose P, coarse operators inherit Q through RAP, and the coarse solve uses SVD for the rigid rotations. A separate green block beside it, labelled "Fieldsplit / Schur solve", carries the pressure and constraints and is marked as never seeing a rotated vector. A single arrow labelled v equals Q v-hat leaves the velocity block at its boundary and branches, one branch entering the Schur block and the other leaving for output, advection and the surface update. + +Where the rotation lives. The obligation is contained: the velocity solve is +rotated and carries its multigrid with it, while the Schur complement and the +pressure solve beside it never handle a rotated vector, because the pressure +block carries no boundary condition of this kind. One un-rotation sits on the +boundary between them and feeds both. +``` + +Four objects carry $Q$: the operator, the right-hand side, the solution on the +way out, and the multigrid prolongation. The coarse operators inherit it +through the Galerkin triple product rather than being rotated separately, and +the coarse solve should be an SVD, because a Galerkin-coarsened rotated +operator inherits any rigid-rotation null space of the constrained problem +(the exact constraint makes the null space of the sphere and the annulus a dominant +feature of the solve). + +We do not know the cost of the addtional complexity on the solver and setup times, +or on the accuracy of the solution but this can be measured and will differ from problem +to problem. + +## Choice of the surface normal + +In a discrete representation of a curved surface, the normal can be defined in various +ways. The boundary of a +discretised domain is a set of straight facets, and the assembled constraint is +an integral over those facets. +The node normal consistent with that integral is +the average of the adjacent facet normals **weighted by facet measure** — not +the normal of the smooth surface the mesh approximates, and not the facet +normal on its own.**This is the consistent normal of Engelman, Sani and Gresho** +[@Engelman_1982]. They derived this result in 1982 from global conservation of mass. + +The analytic normal is exact for the geometry and therefore inconsistent +with the discretisation: the solver is not solving +on the sphere (or annulus), it is solving on the polyhedral approximation to the sphere. + +Using the **facet** normal is worse than inconsistent, and this is the one place +where the wrong choice does real damage. In 2D, Imposing +$\mathbf{u}\cdot\hat{\mathbf{n}} = 0$ facet by facet asks a node shared by two +facets to satisfy two different constraints, and two independent constraints on a +two-component velocity provide no freedom. Push the penalty higher, and the vertex +velocities go to zero: the flow is being asked to stay inside a polygon rather +than a circle, and the discrete limit is a different problem from the smooth one. +Refining the mesh does not approach the smooth answer, because it is not +converging to it. + +On an annulus with a free slip boundary, the direct-penalty approach locks at high penalty values ($\sim 10^6$) if facet normals are used in the +constraint equation. In the figure below, the node-normal approach does solve and reproduces the analytic solution (described in detail in the next section) + +```{figure} figures/locking.png +:alt: Three annulus solutions side by side on one colour scale from zero to 5.0e-3, blue for slow speeds and red for fast, with the triangular mesh drawn over each. The left panel is the exact solution: two deep red patches of fast flow sit against the outer boundary on the left and right of the annulus, with a blue slow ring inside them. The middle panel is the same problem solved with a direct penalty against the facet normal: the red patches at the outer boundary are gone and the whole outer half is blue, the peak speed having fallen from 5.0e-3 to 3.8e-3, while a pale ring survives near the inner boundary. The right panel is the same penalty against the measure-weighted node normal and is indistinguishable from the exact panel, with a peak speed of 5.0e-3. + +The same problem, the same coefficient, the same colour scale. Against the facet +normal the flow along the outer boundary is suppressed — the peak speed falls by +a quarter and the two fast lobes at the boundary are gone. Against the +measure-weighted node normal it is the exact solution. +``` + +Everything that follows uses the node normal, which is what `add_nitsche_bc` and +`add_rotated_freeslip_bc` take by default and what `mesh.boundary_normal` returns. +The facet normal does not appear again. + +## When the choice of constraint matters + +Solve a convection model with any of these approaches, and the +velocity field is the same to plotting accuracy. +Generally speaking, a leak of order $10^{-3}$ or $10^{-4}$ in $\mathbf{u}\cdot\hat{\mathbf{n}}$ is +within the expected accuracy of the solution on the mesh and the main +driver of which method to choose should be solver efficiency (wall time). + +The difference in the methods appears when the wall-normal traction is a +required output of the model: dynamic topography, geoid, gravity, +or a plate-boundary force balance require accurate integration of boundary stresses. +Here the choice becomes more subtle, +and the methods have quite different accuracies, and different efficiencies. + +### The benchmark + +Kramer, Davies and Wilson [@Kramer_2021] give exact Stokes solutions in a +cylindrical annulus, and their `assess` package publishes the radial stress as +well as the velocity, which is what makes it an oracle for this question rather +than only for the flow. Underworld wraps it as `uw.analytic.CylindricalStokes`. + +The case used throughout is the smooth one: a density anomaly +$(r/r_o)^k \cos n\theta$ with $n = 2$ and $k = 3$, viscosity 1, free slip on both +radii. On the outer boundary the exact radial stress is a single harmonic, + +$$ +\sigma_{rr}(r_o, \theta) = 0.1506696\,\cos 2\theta , +$$ + +fitted to a residual of $10^{-16}$, so the whole of the surface stress is that one +amplitude and the error in it is one number. The treatment under test is on the +outer radius; the inner carries the exact analytic velocity as a Dirichlet +condition, so it is the only free-slip condition in the model. + +Two things are measured on every solve: + +- **the surface permeability** — the largest $\mathbf{u}\cdot\hat{\mathbf{n}}$ on the outer + boundary, against the true radial direction, divided by the flow speed. The + fraction of the flow going through an impermeable boundary. +- **the boundary stress error** — the relative error in that harmonic amplitude, recovered + from the solved fields by projection, which is the route every method has available. + +Penalty at $\kappa = 10^4$, Nitsche at $\gamma = 10$. + +| cell size | penalty | Nitsche | multiplier | rotated | +|---|---|---|---|---| +| 0.150 | 3.1 × 10⁻³ / 2.5 × 10⁻² | 1.0 × 10⁻² / 5.8 × 10⁻² | 2.3 × 10⁻⁴ / 2.4 × 10⁻² | 5.3 × 10⁻¹¹ / 2.4 × 10⁻² | +| 0.100 | 3.0 × 10⁻³ / 1.1 × 10⁻² | 2.4 × 10⁻³ / 2.4 × 10⁻² | 1.0 × 10⁻⁴ / 1.0 × 10⁻² | 5.8 × 10⁻¹¹ / 1.0 × 10⁻² | +| 0.075 | 3.0 × 10⁻³ / 7.2 × 10⁻³ | 1.2 × 10⁻³ / 1.5 × 10⁻² | 8.6 × 10⁻⁵ / 6.3 × 10⁻³ | 1.2 × 10⁻¹⁰ / 6.2 × 10⁻³ | +| 0.050 | 3.0 × 10⁻³ / 3.6 × 10⁻³ | 2.7 × 10⁻⁴ / 6.3 × 10⁻³ | 6.7 × 10⁻⁵ / 2.7 × 10⁻³ | 1.2 × 10⁻¹⁰ / 2.7 × 10⁻³ | + +Reading the leak first, Nitsche leaks parts in a thousand and improves with the mesh +— the rate consistency buys. The multiplier is an order of magnitude better and +improves faster. The rotated constraint does not move: it sits at the solver's +floor at every resolution, because the mesh has nothing to do with it. The +penalty does not improve either, and for the opposite reason — its leak is set by +the penalty coefficient rather than by the discretisation. + +Now read the stress beside it, and the ranking is not the same. **Every treatment +that imposes the constraint properly lands on the same stress error at a given +mesh**: 6.3 × 10⁻³ for the multiplier and 6.2 × 10⁻³ for the rotated constraint at +cell 0.075, where their leaks differ by nine orders of magnitude. What sets that +number is the recovery — a projection of a stress differentiated out of a +piecewise-quadratic velocity — and not the boundary condition underneath it. A +constraint held to $10^{-10}$ buys nothing over one held to $10^{-4}$ if the +answer is then recovered the same way. + +Nitsche is the exception, at twice the error of the others on the coarser meshes. +Its $\gamma = 10$ is enough for the leak and not for the stress: at +$\gamma = 100$ the leak improves by a factor of nearly forty and the stress by a +factor of two, onto the same floor as everything else, after which more $\gamma$ +buys nothing. + +### The traction the solve already has + +The two exact treatments do not have to recover anything, and the difference +shows up against the same exact answer: + +| cell size | rotated, reaction | multiplier, traction | either, recovered by projection | +|---|---|---|---| +| 0.150 | 6.8 × 10⁻³ | 8.6 × 10⁻³ | 2.4 × 10⁻² | +| 0.100 | 3.3 × 10⁻³ | 8.5 × 10⁻⁴ | 1.0 × 10⁻² | +| 0.075 | 2.1 × 10⁻³ | 1.7 × 10⁻³ | 6.3 × 10⁻³ | +| 0.050 | 1.1 × 10⁻³ | 1.4 × 10⁻³ | 2.7 × 10⁻³ | + +Three to five times better than the projection on the same solve, at every +resolution, using the expressions given with each method above. Neither column +falls smoothly with $h$: part of what they report is the constraint residual, and +how far a particular solve drove that is not a function of the mesh. + +### What each parameter buys + +The two weak methods look alike in the comparison above, but this is for a fixed, tuned penalty parameter. + +| $\kappa$ (penalty) | leak | | $\gamma$ (Nitsche) | leak | +|---|---|---|---|---| +| 10² | 2.6 × 10⁻¹ | | 1 | diverged | +| 10³ | 2.6 × 10⁻² | | 10 | 1.7 × 10⁻³ | +| 10⁴ | 2.6 × 10⁻³ | | 100 | 2.7 × 10⁻⁴ | +| 10⁵ | 3.0 × 10⁻⁴ | | 1000 | 3.0 × 10⁻⁵ | +| 10⁶ | 4.5 × 10⁻⁵ | | 10⁴ and above | diverged | + +Nitsche is bounded at both ends. Below $\gamma \sim 1$ the form is no longer +coercive and no amount of solver tuning recovers a solution; from $\gamma \sim 10^4$ in this +problem, the line search stops converging. The virtue of $\gamma \sim 10$ is that it +sits within that window on any mesh, because $\gamma$ is dimensionless +and the term it scales already carries $\mu / h$. + +The penalty coefficient scales differently because it directly penalises the +value of the velocity across the boundary. It should therefore scale with the +characteristic velocity which is best estimated from the magnitude of the forcing +terms and the resisting viscosity. + +Written against the node normal, the penalty simply trades: a decade of +coefficient for a decade of leak, all the way to $10^6$, with no wall in this +problem. What it does not do is converge with resolution — the leak is bought with the parameter +rather than with the mesh resolution — so the coefficient has to be re-chosen whenever the +forcing or the viscosity changes. + +### The multiplier and the consistent boundary flux are the same + +The two expressions given above for computing topography from the boundary reaction are exactly equivalent. +Write the momentum row's boundary term out and the identity is immediate: the assembled +load is $M_\Gamma\,(\lambda + r(\mathbf{u}\cdot\hat{\mathbf{n}} - \tilde{u}_n))$, +and at convergence it +balances the volume residual restricted to the boundary, which is precisely the +nodal load the consistent boundary flux back-calculation reads +[@Zhong_1993]. So + +$$ +\lambda + r(\mathbf{u}\cdot\hat{\mathbf{n}} - \tilde{u}_n) + = -M_\Gamma^{-1} \left. (A\mathbf{u} - \mathbf{b}) \right|_\Gamma , +$$ + +which is the rotated constraint's reaction, de-smeared with the same boundary +mass. The multiplier is not a second, independent estimate of the surface stress: +it is the same computation, arrived at by carrying the traction as an unknown +instead of reading it out of the residual afterwards. + +### The other half: a lateral viscosity contrast + +No exact solution has both a curved boundary and a laterally varying viscosity, +so the case where weak constraints are most often reported to give trouble is a +separate test with a trivial geometry. SolCx is that test: the unit box, free +slip on all four walls, viscosity 1 to the left of $x = 0.5$ and $\eta_B$ to the +right. `uw.analytic.SolCx` publishes the exact dynamic topography on the top +wall. Three walls carry the ordinary component condition and the treatment under +test is on the top wall alone. + +On a box every treatment reduces to holding one velocity component, so nothing +here is about normals. What it can say is whether a treatment holds the traction +it was given when the viscosity beside it jumps. + +Relative $l_2$ error of the surface topography along the top wall, mean removed, +at 32 × 32 elements. Each entry is the whole wall and then the wall with two +elements trimmed from each end. + +| $\eta_B/\eta_A$ | component Dirichlet | penalty, $10^4$ | multiplier | rotated | +|---|---|---|---|---| +| 10 | 0.048 / 0.054 | 0.045 / 0.051 | 0.048 / 0.054 | 0.048 / 0.054 | +| 10² | 0.072 / 0.081 | 0.056 / 0.060 | 0.072 / 0.081 | 0.072 / 0.081 | +| 10³ | 0.075 / 0.084 | 0.234 / 0.230 | 0.075 / 0.084 | 0.075 / 0.084 | +| 10⁴ | 0.076 / 0.085 | 0.698 / 0.697 | 0.076 / 0.085 | 0.076 / 0.085 | +| 10⁶ | 0.076 / 0.085 | 0.992 / 1.000 | 0.075 / 0.084 | 0.076 / 0.085 | + +```{figure} figures/topography.png +:alt: Two line plots of surface topography along the top wall from x=0 to x=1, mean removed, at viscosity contrasts of 100 and a million. In both, the exact answer is a thick grey curve falling from +0.29 at the left, flattening near +0.21, dropping sharply at the viscosity step at x=0.5 and continuing down to -0.38 at the right. At a contrast of 100 every curve lies on the grey one. At a contrast of a million they separate: the component Dirichlet, the rotated reaction and the traction lambda + r(u.n - u_n) still lie on the exact curve, while the multiplier field lambda alone is a nearly flat line near zero reaching only 0.04, and the penalty at 1e4 is a second nearly flat line near zero. Nitsche does not solve at either contrast and is absent. + +Surface topography along the top wall, against the exact answer. At a contrast of +100 nothing distinguishes the treatments. At $10^6$ the multiplier field +$\lambda$ carries almost none of the traction on its own — the augmentation +holds the rest — while $\lambda + r(\mathbf{u}\cdot\hat{\mathbf{n}} - +\tilde{u}_n)$, which is what `traction()` returns, lies on the exact curve. The +penalty has failed by this contrast: its coefficient is a bare number and cannot +be large against $10^6$ and moderate against 1 at the same time. +``` + +**The three exact treatments agree to three figures at every contrast**, whole +wall and trimmed alike. That is the result to take from this half, and it took +the multiplier reporting the whole traction rather than $\lambda$ alone, and the +rotated constraint holding the corner where it meets the side walls. + +**Read the first column as the floor.** The component Dirichlet condition is +exact and has no parameter, and its velocity error is 8.8 × 10⁻⁶ at a contrast of +$10^6$. It still reads 0.085. That number is the recovery's error, not a boundary +condition's: on the stiff half the recovered $\sigma_{zz}$ is a difference between +the pressure and $2\eta\,\partial_z u_z$ with $\eta = 10^6$, so a relative velocity +error of $10^{-5}$ appears in the stress. + +**A bare penalty coefficient cannot serve both halves.** At $10^4$ it is the best +column in the table at low contrast — the constraint is weak enough not to fight +the recovery — and by $10^6$ it is meaningless: 0.992, which is to say the recovered +topography carries none of the signal. Scaling the coefficient by the local +viscosity is the obviously right thing to want, but the solver does not converge here at any +magnitude we tried, from $\eta$ to $10^3\eta$. + +**Nitsche is missing from this table**. Our configuration of it +on this box converges at a contrast of $10^6$ and fails the line search at $10$ — +the opposite way round from every expectation — at 16 × 16 and 32 × 32 alike and +at $\gamma = 10$, $100$ and $1000$. Where it does converge, $\gamma$ has to rise +with the contrast exactly as the annulus said: at $10^6$ and 16 × 16 the surface +stress error is 25 at $\gamma = 10$, 1.2 at $100$ and 0.17 at $1000$, while the +constraint is held to $10^{-3}$ or better throughout. We are not confident enough +in that configuration to put a column of numbers behind it. + +### What each one costs + +Seconds on the annulus, uniform viscosity, one core, direct solver: the solve, +and then the surface traction by whatever route that treatment has. Median of +three timed repeats after an untimed warm-up, run sequentially. Two sizes, +because below about ten thousand nodes the four are separated by less than the +run-to-run spread and there is nothing to read. + +| velocity nodes | penalty | Nitsche | multiplier | rotated | +|---|---|---|---|---| +| 28 338 | 0.17 / 0.273 | 0.18 / 0.267 | 0.26 / — | 0.16 / 0.010 | +| 71 424 | 0.45 / 0.631 | 0.47 / 0.657 | 0.67 / — | 0.43 / 0.016 | + +The dash indicates that the multiplier does not require any additional *solver* — +$\lambda$ is a finite element field in its own right, so its nodal values are the +traction, pointwise, and $\lambda + r(\mathbf{u}\cdot\hat{\mathbf{n}} - \tilde{u}_n)$ is an +expression evaluated where it is wanted. + +**The rotated constraint's reaction does need one step**. (The 10 to 16 ms in the table is the reaction the solve +already stashed; `boundary_flux` re-assembles the residual from scratch and costs +0.2 s, which is the 0.206 s in the comparison above.) The reaction is an *integrated* nodal load, +$\int_\Gamma \sigma_{nn}\,\phi_i\,\mathrm{d}S$, which is $M_\Gamma$ times the +pointwise traction. Turning it into a pointwise value means undoing that boundary +mass. On a 2-D trace, and on 3-D P1 triangles, the lumped mass is diagonal and +undoing it is a division — the 10 to 16 ms above. On **3-D P2 triangles it is a +true solve**: the lumped row sums vanish at the vertices, so the consistent +trace mass has to be assembled and solved. That is the one place the CBF +route pays for being a +back-calculation, and it is the case a spherical free surface runs in. + +**The multiplier's solve costs about 50% more**, consistently — 0.67 s against +0.43 s at 71 000 nodes. That is the extra field and the larger saddle point. +Rotating the degrees of freedom costs nothing measurable against the weak forms: +the rotation is a sparse orthogonal transform on a boundary's worth of rows. + +**The weak constraints have to recover surface stress by differentiating the solution**, and +the projection that does it costs *more than the Stokes solve did* — 0.63 s +against 0.45 s — so asking a penalty or Nitsche model for its surface stress +roughly doubles the timestep. + +The obvious question is whether that is the method's cost or the recovery's. A +global $L^2$ projection to get values on a thousand boundary nodes is plainly +more work than the job requires, and the consistent boundary flux is an available alternative, +reading the assembled residual rather than differentiating anything. However, we find that **it does +not work for a weakly imposed condition**, and the reason is the same one that +makes it unavailable to the multiplier: + +| cell 0.0125 | projection | CBF back-calculation | +|---|---|---| +| penalty, node normal | 0.651 s, error 1.1 × 10⁻³ | 0.224 s, **error 1.00** | +| Nitsche | 0.666 s, error 3.6 × 10⁻⁴ | 0.230 s, **error 1.00** | +| rotated | 0.602 s, error 1.6 × 10⁻⁴ | 0.206 s, error 1.6 × 10⁻⁴ | + +An error of 1.00 is the metric reporting that nothing was recovered. A reaction +exists in the residual only where a row has been *constrained*; a weak condition +supplies its traction as a term inside the row it acts on, so the residual there +is balanced at convergence and there is nothing left to read. The multiplier does +the same thing, and gets away with it because the term it supplies, $\lambda$, is the +traction as a field. Nitsche's term is written in terms of +$\boldsymbol{\sigma}(\mathbf{u})$, so reading it back still requires differentiating +the answer. + +That is the structural statement the timings are really making, and it follows +the two pairs exactly: + +| how the constraint is imposed | the traction is | to read it | +|---|---|---| +| weakly, by a term (penalty, Nitsche) | a by-product | differentiate the solution | +| exactly, by construction (rotated) | the constraint reaction | de-smear the nodal load | +| exactly, by a multiplier | an unknown of the system | read the field | + +The cost of the first row is negotiable — a recovery restricted to the boundary +would be cheaper than a global projection — but the differentiation is not. + +:::{note} What these timings are not +Two-dimensional, one core, direct solver. What they measure is the difference +between a recovery *solve* and boundary arithmetic, which is structural and +survives scaling. They say nothing about a large parallel spherical shell, where +the rotated velocity block's multigrid and the multiplier's larger Schur +complement are the terms that matter and neither is exercised here. +::: + +### Which one to use + +For a model that consumes the velocity and nothing else, all four are the same to +plotting accuracy — provided the constraint is written against the node normal. +That proviso is the only one that can spoil the velocity, and it costs one line. + +When the wall-normal traction is needed: + +- **Rotated free slip is the default.** It holds the constraint to machine + precision rather than to the discretisation, its reaction is the most accurate + surface stress measured here, and that reaction is nearly free. The price is + structural — a mixed basis that the multigrid has to carry — and it is paid + once, inside the solver, rather than by the person setting up the model. +- **The multiplier is its equal on accuracy** and returns the same object by a + different route; take it when you want the traction as an unknown of the + system, or when the constrained problem's conditioning suits you better. It + costs about 50% more to solve. +- **Nitsche is the one to reach for when the boundary condition must change + during the model** — a wall that begins as a prescribed velocity and relaxes to + a prescribed traction is a Nitsche problem, because a hard constraint cannot + morph. Budget for tuning $\gamma$ against the stress and not against the leak, + and expect the window to move with the viscosity contrast. If the viscosity + contrast is large with jumps or strong gradients along the boundary, be very careful + if you choose Nitsche. +- **A direct penalty is fine for a velocity-only model** and needs the node + normal, a coefficient chosen per problem, and a check on something physical + before the answer is believed. It has the advantage that this is pure, direct penalty + on the weak form and can be used for many things beyond simply boundary conditions. + Good for a first pass on a very general idea. + + +## Using it + +```python +import underworld3 as uw + +mesh = uw.meshing.Annulus(radiusInner=0.5, radiusOuter=1.0, cellSize=0.05) +stokes = uw.systems.Stokes(mesh) + +# Value first: 0 is free slip. A non-zero scalar or expression prescribes the +# wall-normal datum u.n = u_n strongly instead. +stokes.add_rotated_freeslip_bc(0.0, "Upper") +stokes.add_rotated_freeslip_bc(0.0, "Lower") + +stokes.solve() + +# The constraint reaction, which is the boundary normal traction. +sigma_nn = stokes.boundary_normal_traction("Upper") +``` + +Leave the normal to Underworld unless the constraint has to follow the true +surface rather than the mesh. Passing an analytic normal — `X / |X|` on a +sphere — is exact for the geometry and keeps a consistency error against the +faceted assembly, which is usually not what you want. + +Reach for Nitsche when the boundary condition has to **change during the +model**. A hard constraint cannot morph: a wall that begins as a prescribed +velocity and relaxes to a prescribed traction is a Nitsche problem, because the +rotated constraint is either imposed or it is not. + +
Comments
Discussion of these notes happens in GitHub Discussions, so it stays with the source and is searchable alongside it.
diff --git a/articles/boundary-conditions-on-non-planar-boundaries/examples/README-stress-test.md b/articles/boundary-conditions-on-non-planar-boundaries/examples/README-stress-test.md new file mode 100644 index 0000000..87730a0 --- /dev/null +++ b/articles/boundary-conditions-on-non-planar-boundaries/examples/README-stress-test.md @@ -0,0 +1,49 @@ +# The examples, and what each one measures + +Every table and figure in the note is produced by one of these. Run them from +this directory with an Underworld3 environment on the path. + +| script | what it produces | +|---|---| +| `leak.py` | the constraint tables — how much flow each treatment lets through, under refinement (`sweep`) and against its own parameter (`params`) | +| `stress.py` | the surface-stress tables against the exact `uw.analytic.CylindricalStokes` answer: `sweep`, `params`, `locking`, `control` | +| `solcx.py` | the lateral-viscosity half, against `uw.analytic.SolCx`: `sweep`, `contrast`, `params`, `control` | +| `generate-locking-figure.py` | `figures/locking.png` and `figures/banner.png` | +| `generate-rotated-basis.py` | the data behind `rotated-basis.typ`, which draws `figures/rotated-basis.svg` | + +## The traps, all paid for once + +- **`v.array` is `(N, 1, dim)`.** It broadcasts silently against `(N, dim)` + normals and returns ~1e-16 projections for a 1e-2 velocity, with no error. + `np.squeeze` it. Every number in the first leak run was this artefact. +- **Give every metric a negative control and run it.** Two free-slip circles + leave the rigid rotation unconstrained, and that nullspace is purely + tangential — so a radial leak metric read 2e-14 on a solve that had diverged + with `|u| = 2.7e5`. The inner boundary is now held. +- **Check `snes.getConvergedReason() > 0` before tabulating anything.** Diverged + runs leave plausible numbers in the array; two nearly reached the note. +- **Vertex against edge midpoint.** On a curved boundary, vertex values of + `sigma_nn` carry the O(h) facet error and midpoints are superconvergent + (underworld3#414). `stress.py` splits them and the note says which it uses. +- **Select a boundary trace by the mesh LABEL, not by a radius band.** A band + that narrows with the mesh admits a different node set at each resolution, and + the earlier version of this comparison drifted by four nodes between methods + because of it. +- **Enclosed domain**: the multiplier, the pressure and the traction are each + determined only up to a constant. Compare deviations, which is what topography + is anyway. +- **A `Piecewise` viscosity inside a boundary penalty term does not solve** on + SolCx, at any magnitude tried. + +## What is still open + +- The spherical case. `uw.analytic.Zhong2008` publishes + `.response().surface_topography = 0.4191904156575601` for the default + degree-2 case (load r = 0.775, r_inner = 0.55, isoviscous), which is the right + oracle for dynamic topography proper. It is a 3-D shell, and that is the cost + step this note stopped short of. +- Behr (2004) reports non-physical recirculation at curved walls even with the + consistent normal. We have not looked for it here. +- underworld3 issues #607 (the multiplier misses the augmented-Lagrangian share) + and #608 (the rotated constraint at a corner shared with a component + condition) both came out of these runs and are open. diff --git a/articles/boundary-conditions-on-non-planar-boundaries/examples/generate-locking-figure.py b/articles/boundary-conditions-on-non-planar-boundaries/examples/generate-locking-figure.py new file mode 100644 index 0000000..e2f51b3 --- /dev/null +++ b/articles/boundary-conditions-on-non-planar-boundaries/examples/generate-locking-figure.py @@ -0,0 +1,106 @@ +"""The figure for "A constraint that is satisfied, and wrong". + +Three solves of the same annulus problem, rendered with the same colour scale: +the exact solution, a direct penalty written against the facet normal, and the +same penalty written against the measure-weighted node normal. The coefficient +is 1e6 in both penalty panels, so the only difference between them is the +normal. + +Writes: + figures/locking.png the three-panel figure + figures/banner.png a wide two-panel crop for the article banner + + python3 generate-locking-figure.py + +Run against underworld3 `bugfix/multiplier-traction` (PR #617); the +constrained solver's `traction()` is the fix this note prompted. +""" +import pathlib + +import numpy as np +import pyvista as pv + +pv.OFF_SCREEN = True + +import underworld3 as uw +import underworld3.visualisation as vis + +import stress as S + +CELL = 0.075 +PENALTY = 1.0e6 +HERE = pathlib.Path(__file__).resolve().parent +FIGURES = HERE.parent / "figures" + + +def speed_mesh(mode): + """(pyvista mesh carrying |u|, mesh edges, max speed) for one treatment.""" + mesh, stokes, v, exact = S.build(mode, cell=CELL, penalty=PENALTY) + stokes.solve() + assert S.converged(stokes), "%s did not converge" % mode + pv_v = vis.meshVariable_to_pv_mesh_object(v) + u = np.squeeze(np.asarray(v.array)) + pv_v.point_data["speed"] = np.linalg.norm(u, axis=1) + edges = vis.mesh_to_pv_mesh(mesh).extract_all_edges() + return pv_v, edges, exact, v + + +def exact_speed_mesh(reference): + """The same object built from the exact velocity, for the first panel.""" + mesh, stokes, v, exact = reference + field = uw.discretisation.MeshVariable("Uplot", v.mesh, v.mesh.dim, degree=2) + field.data[:] = exact.evaluate("velocity", field.coords) + pv_v = vis.meshVariable_to_pv_mesh_object(field) + pv_v.point_data["speed"] = np.linalg.norm( + np.squeeze(np.asarray(field.array)), axis=1) + return pv_v + + +def panel(plotter, index, pv_mesh, edges, title, clim, zoom=1.3): + plotter.subplot(0, index) + plotter.set_background("white") + plotter.add_mesh(pv_mesh, scalars="speed", cmap="RdBu_r", clim=clim, + show_edges=False, lighting=False, show_scalar_bar=False) + plotter.add_mesh(edges, color="black", line_width=0.4, lighting=False) + plotter.add_text(title, position="upper_left", font_size=12, color="black") + plotter.view_xy() + plotter.camera.zoom(zoom) + + +def main(): + facet_pv, facet_edges, exact, facet_v = speed_mesh("penalty") + node_pv, node_edges, _exact, node_v = speed_mesh("penalty_node") + + # The exact field on the node-normal run's mesh, which is the same mesh. + truth = uw.discretisation.MeshVariable("Utruth", node_v.mesh, node_v.mesh.dim, + degree=2) + truth.data[:] = _exact.evaluate("velocity", truth.coords) + truth_pv = vis.meshVariable_to_pv_mesh_object(truth) + truth_pv.point_data["speed"] = np.linalg.norm( + np.squeeze(np.asarray(truth.array)), axis=1) + + top = float(truth_pv.point_data["speed"].max()) + clim = (0.0, top) + print("colour scale 0 to %.4e" % top) + for name, pv_mesh in (("exact", truth_pv), ("facet", facet_pv), ("node", node_pv)): + print("%-6s max speed %.4e" % (name, float(pv_mesh.point_data["speed"].max()))) + + FIGURES.mkdir(exist_ok=True) + + plotter = pv.Plotter(off_screen=True, shape=(1, 3), window_size=(1650, 620)) + panel(plotter, 0, truth_pv, node_edges, "exact", clim) + panel(plotter, 1, facet_pv, facet_edges, "penalty, facet normal", clim) + panel(plotter, 2, node_pv, node_edges, "penalty, node normal", clim) + plotter.screenshot(str(FIGURES / "locking.png")) + plotter.close() + + banner = pv.Plotter(off_screen=True, shape=(1, 2), window_size=(1600, 560)) + panel(banner, 0, facet_pv, facet_edges, "", clim, zoom=1.9) + panel(banner, 1, truth_pv, node_edges, "", clim, zoom=1.9) + banner.screenshot(str(FIGURES / "banner.png")) + banner.close() + print("wrote", FIGURES / "locking.png", "and", FIGURES / "banner.png") + + +if __name__ == "__main__": + main() diff --git a/articles/boundary-conditions-on-non-planar-boundaries/examples/generate-rotated-basis.py b/articles/boundary-conditions-on-non-planar-boundaries/examples/generate-rotated-basis.py new file mode 100644 index 0000000..bfa3a74 --- /dev/null +++ b/articles/boundary-conditions-on-non-planar-boundaries/examples/generate-rotated-basis.py @@ -0,0 +1,102 @@ +"""Geometry for the rotated-boundary-conditions figure. + +The cetz skill's rule: geometry computation happens in Python and Typst just +draws. Doing the triangulation here rather than in the figure is what stops +nodes being left out of the mesh -- an earlier version connected nodes by a +distance threshold and silently missed several. + +Emits the schema in the skill's `underworld-bridge.md`: + + {"vertices": [[x, y], ...], + "triangles": [[i, j, k], ...], + "surface": [i, ...], indices of the constrained nodes + "frames": [{"p": [x, y], "n": [nx, ny], "t": [tx, ty]}, ...], + "curve": [[x, y], ...]} the surface, finely sampled + +Run: python3 generate-rotated-basis.py +""" +import json +import pathlib + +import numpy as np +from scipy.spatial import Delaunay + +OUT = pathlib.Path(__file__).with_name("rotated-basis-data.json") + +X0, X1 = -3.5, 3.5 +BASE = -2.9 +NX = 9 # columns of nodes +NY = 4 # rows, surface included + + +def surface_y(x): + """A deformed surface: rises on the left, falls on the right, with an + inflection between, so the normal swings through a wide range and the + curvature changes sign. No global rotation straightens this out, which is + the reason the figure exists.""" + x = np.asarray(x, dtype=float) + return (0.95 * np.exp(-(((x + 1.75) / 1.30) ** 2)) + - 0.80 * np.exp(-(((x - 1.70) / 1.15) ** 2)) + + 0.75) + + +def surface_slope(x, eps=1.0e-4): + return (surface_y(x + eps) - surface_y(x - eps)) / (2 * eps) + + +# Nodes on a grid warped to sit under the surface. Columns are staggered on +# alternate rows so the Delaunay triangulation comes out as triangles rather +# than as near-degenerate right angles on a perfect lattice. +xs = np.linspace(X0, X1, NX) +pts = [] +surface_idx = [] +for row in range(NY): + frac = row / (NY - 1) # 0 at the base, 1 at the surface + offset = 0.0 if row % 2 == 0 else 0.5 * (xs[1] - xs[0]) + cols = xs + offset + if row == NY - 1: + cols = xs # surface row unstaggered + for x in cols: + if x < X0 - 1e-9 or x > X1 + 1e-9: + continue + top = float(surface_y(x)) + y = BASE + (top - BASE) * frac + if row == NY - 1: + surface_idx.append(len(pts)) + pts.append([float(x), float(y)]) + +pts = np.array(pts) +tri = Delaunay(pts) + +# Drop the slivers Delaunay leaves along a non-convex top edge: any triangle +# whose centroid sits above the surface is outside the domain. +keep = [] +for simplex in tri.simplices: + c = pts[simplex].mean(axis=0) + if c[1] <= float(surface_y(c[0])) + 1.0e-9: + keep.append([int(i) for i in simplex]) + +frames = [] +for i in surface_idx: + x = pts[i][0] + m = float(surface_slope(x)) + n = np.array([-m, 1.0]) + n /= np.linalg.norm(n) + t = np.array([1.0, m]) + t /= np.linalg.norm(t) + frames.append({"p": [float(pts[i][0]), float(pts[i][1])], + "n": [float(n[0]), float(n[1])], + "t": [float(t[0]), float(t[1])]}) + +curve_x = np.linspace(X0, X1, 121) +data = { + "vertices": [[float(a), float(b)] for a, b in pts], + "triangles": keep, + "surface": [int(i) for i in surface_idx], + "frames": frames, + "curve": [[float(a), float(b)] for a, b in zip(curve_x, surface_y(curve_x))], +} +OUT.write_text(json.dumps(data, indent=1)) +print("wrote %s: %d vertices, %d triangles, %d surface nodes" + % (OUT.name, len(data["vertices"]), len(data["triangles"]), + len(data["surface"]))) diff --git a/articles/boundary-conditions-on-non-planar-boundaries/examples/generate-topography-figure.py b/articles/boundary-conditions-on-non-planar-boundaries/examples/generate-topography-figure.py new file mode 100644 index 0000000..a295f55 --- /dev/null +++ b/articles/boundary-conditions-on-non-planar-boundaries/examples/generate-topography-figure.py @@ -0,0 +1,230 @@ +"""Figure: the surface topography each treatment predicts, against the exact one. + +SolCx publishes the exact dynamic topography on the top wall +(`uw.analytic.SolCx.topography_top`, which is -sigma_zz), so the comparison can +be drawn rather than tabulated. Two panels, the same five curves in each, at a +viscosity contrast of 100 and of a million. + +The topography is mean-removed in every case: the box is enclosed, so the +pressure and with it the level of sigma_zz is fixed only up to a constant, and +the deviation is the part that is determined and the part topography is built +from. + +Two routes are drawn, and they are not the same measurement: + + * the recovered traction, projected out of the solved velocity and pressure, + which is the only route the weak treatments have; + * the constraint reaction -- `boundary_normal_traction` for the rotated + constraint and the multiplier field for the constraint method -- which the + solve returns as an unknown. Its sign convention is the traction holding the + wall, which is the topography's sign directly. + +Colour carries the treatment and the exact answer is a thick grey line behind +everything, so no curve is identified by colour alone against the reference. + + python3 generate-topography-figure.py + +Writes figures/topography.png. Run against underworld3 `development` at commit +`8b7c8b9e`. +""" +import json +import pathlib +import sys + +import numpy as np + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +import solcx as C + +HERE = pathlib.Path(__file__).resolve().parent +OUT = HERE.parent / "figures" / "topography.png" +# The solves take about ten minutes and the figure is redrawn far more often +# than it is recomputed, so the curves are cached beside the script the way +# `rotated-basis-data.json` is. Delete it, or pass --recompute, to re-solve. +CACHE = HERE / "topography-data.json" + +RES = 32 +CONTRASTS = (1.0e2, 1.0e6) +MODES = ("dirichlet", "penalty", "nitsche", "constraint", "rotated") + +# Validated categorical palette, as in the multigrid note's figure. +COLOUR = { + "dirichlet": "#0b0b0b", + "penalty": "#eb6834", + "nitsche": "#c13ec1", + "constraint": "#2a78d6", + "constraint+r": "#2a78d6", + "rotated": "#1e9e6a", +} +LABEL = { + "dirichlet": "component Dirichlet", + "penalty": "penalty, $10^4$", + "nitsche": r"Nitsche, $\gamma = 10$", + "constraint": r"multiplier field $\lambda$", + "constraint+r": r"traction $\lambda + r(\mathbf{u}\cdot\hat{\mathbf{n}} - \tilde{u}_n)$", + "rotated": "rotated (reaction)", +} +STYLE = {"dirichlet": (0, (4, 2)), "penalty": "-", "nitsche": "-", + "constraint": "-", "constraint+r": (0, (1, 1.6)), "rotated": "-"} +INK = "#0b0b0b" +INK_MUTED = "#52514e" +GRID = "#e4e3df" +EXACT = "#b9b7b2" + + +def profile(mode, eta_B): + """{name: (x, topography)} along the top wall, mean removed, and the exact one. + + The reaction where there is one, the recovered traction otherwise: that is + what a user of each treatment would actually have. The constraint method + returns two curves -- the multiplier as the API returns it, and the + multiplier plus the augmented-Lagrangian share r(u.n - g), which is the + other half of the traction the momentum row carries (underworld3#607). + """ + mesh, stokes, v, exact = C.build(mode, res=RES, eta_B=eta_B) + stokes.solve() + if not C.converged(stokes): + print("%-10s eta_B %.0e diverged" % (mode, eta_B), flush=True) + return None + read = C.reaction_traction(stokes, mode) + if read is None: + coords, values = C.recovered_traction(mesh, stokes) + curves = {mode: -np.asarray(values)} # h = -sigma_zz + else: + coords, values = read + curves = {mode: np.asarray(values)} + if mode == "constraint": + # BOTH curves come from the solver, and neither is assembled here. That + # matters: this script used to add r(u.n) to what `reaction_traction` + # returned, which was right while that returned the bare multiplier and + # became a DOUBLE COUNT the moment it returned `traction()` instead -- + # the corrected curve drew at twice its augmentation share and left the + # panel. Two copies of one expression, one of them stale. There is now + # one copy, and it lives in the solver. + # multiplier() -> lambda, the field + # traction() -> lambda + r(u.n - u~_n), the whole boundary load + curves["constraint+r"] = curves[mode] # traction(), as read + coords_bare, bare = C.trace(stokes, 2, stokes.multiplier("Top")) + assert np.allclose(coords_bare, coords), "the two traces disagree" + curves[mode] = np.asarray(bare) # the multiplier alone + + order = np.argsort(coords[:, 0]) + x = coords[order, 0] + truth = exact.topography_top(coords)[order] + truth = truth - truth.mean() + out = {} + for name, values in curves.items(): + got = values[order] - values.mean() + # The sign convention is checked rather than assumed, and a curve that + # comes back anti-correlated is drawn AND named rather than flipped. + correlation = float(np.dot(got, truth) + / (np.linalg.norm(got) * np.linalg.norm(truth) + 1e-300)) + print("%-14s eta_B %.0e max|h| %.4f corr %+.3f l2 %.3f" + % (name, eta_B, np.abs(got).max(), correlation, + np.linalg.norm(got - truth) / np.linalg.norm(truth)), flush=True) + out[name] = (x, got) + return out, (x, truth) + + +def panel(ax, results, truth, title, ylim): + x, exact = truth + ax.plot(x, exact, color=EXACT, linewidth=5.0, solid_capstyle="round", + zorder=1, label="exact") + for mode, (xs, got) in results.items(): + ax.plot(xs, got, linestyle=STYLE[mode], color=COLOUR[mode], + linewidth=1.6, zorder=3, label=LABEL[mode]) + ax.axvline(0.5, color=GRID, linewidth=1.0, zorder=0) + ax.text(0.505, ylim[1] * 0.80, "viscosity step", fontsize=8.5, + color=INK_MUTED, ha="left", va="top") + ax.set_xlabel("$x$ along the top wall", fontsize=9.5, color=INK_MUTED) + ax.set_title(title, fontsize=10.5, color=INK, pad=10) + ax.set_xlim(0.0, 1.0) + ax.set_ylim(*ylim) + ax.grid(True, which="major", color=GRID, linewidth=0.8, zorder=0) + ax.set_axisbelow(True) + for side in ("top", "right"): + ax.spines[side].set_visible(False) + for side in ("left", "bottom"): + ax.spines[side].set_color(GRID) + ax.tick_params(colors=INK_MUTED, labelsize=9) + + +def compute(): + data, truth = {}, {} + for eta_B in CONTRASTS: + got = {} + for mode in MODES: + out = profile(mode, eta_B) + if out is None: + continue + curves, truth[eta_B] = out + got.update(curves) + data[eta_B] = got + CACHE.write_text(json.dumps( + {"contrasts": list(CONTRASTS), + "truth": {str(k): [v[0].tolist(), v[1].tolist()] for k, v in truth.items()}, + "data": {str(k): {m: [c[0].tolist(), c[1].tolist()] for m, c in v.items()} + for k, v in data.items()}})) + return data, truth + + +def cached(): + raw = json.loads(CACHE.read_text()) + truth = {float(k): (np.array(v[0]), np.array(v[1])) + for k, v in raw["truth"].items()} + data = {float(k): {m: (np.array(c[0]), np.array(c[1])) for m, c in v.items()} + for k, v in raw["data"].items()} + return data, truth + + +def main(): + if CACHE.exists() and "--recompute" not in sys.argv: + data, truth = cached() + print("drawn from", CACHE.name, "-- pass --recompute to re-solve") + else: + data, truth = compute() + + span = max(np.abs(truth[e][1]).max() for e in CONTRASTS) + ylim = (-1.6 * span, 1.6 * span) + + fig, axes = plt.subplots(1, 2, figsize=(9.6, 4.3), sharey=True) + fig.patch.set_facecolor("white") + for ax, eta_B in zip(axes, CONTRASTS): + panel(ax, data[eta_B], truth[eta_B], + r"$\eta_B/\eta_A = 10^{%d}$" % int(round(np.log10(eta_B))), ylim) + # Name what left the panel rather than leaving a curve to run off it. + for mode, (xs, got) in data[eta_B].items(): + if np.abs(got).max() > ylim[1]: + ax.annotate("%s leaves the panel:\npeaks at %.2f" + % (LABEL[mode], np.abs(got).max()), + xy=(0.03, ylim[0] * 0.72), fontsize=8.5, + color=COLOUR[mode], linespacing=1.35) + missing = [m for m in MODES if m not in data[eta_B]] + if missing: + ax.annotate("does not solve: %s" % ", ".join(LABEL[m] for m in missing), + xy=(0.03, ylim[1] * 0.86), fontsize=8.5, color=INK_MUTED) + axes[0].set_ylabel("surface topography, mean removed", + fontsize=9.5, color=INK_MUTED) + # The legend goes below the panels: at 1e6 every corner of both axes has a + # curve in it, and a legend inside covered the multiplier's collapse. + # Both panels, deduplicated: the penalty only converges in the left one and + # would otherwise be an unlabelled curve. + handles, labels = [], [] + for ax in axes: + for handle, label in zip(*ax.get_legend_handles_labels()): + if label not in labels: + handles.append(handle) + labels.append(label) + fig.legend(handles, labels, frameon=False, fontsize=9, labelcolor=INK_MUTED, + loc="lower center", ncol=3, handlelength=2.6, + bbox_to_anchor=(0.5, -0.01)) + fig.tight_layout(rect=(0, 0.15, 1, 1)) + fig.savefig(OUT, dpi=200, facecolor="white") + print("wrote", OUT) + + +if __name__ == "__main__": + main() diff --git a/articles/boundary-conditions-on-non-planar-boundaries/examples/leak.py b/articles/boundary-conditions-on-non-planar-boundaries/examples/leak.py new file mode 100644 index 0000000..df43248 --- /dev/null +++ b/articles/boundary-conditions-on-non-planar-boundaries/examples/leak.py @@ -0,0 +1,212 @@ +"""How much flow does each boundary treatment let through? + +The note claims a direct penalty and Nitsche satisfy `u.n = 0` only to the +accuracy of the discretisation, while rotating the degrees of freedom satisfies +it to the accuracy of the arithmetic. This measures that rather than asserting +it. + +The test is an annulus -- a boundary with no preferred direction, which is the +whole point -- driven by a single-wavenumber density anomaly, free slip on both +radii. + +TWO leaks are reported, and the difference between them is the subject of the +note's section on which normal to use: + + * against the FACET normal, which is what the discrete constraint actually + imposes; + * against the TRUE radial direction of the circle the mesh approximates. + +A strongly imposed constraint should be at machine precision against the first +and at the faceting error against the second. A weakly imposed one is limited +by its own penalty long before either. + +Reproduces the table in the note: + + python3 leak.py sweep # the resolution table + python3 leak.py free # the control: outer boundary left natural + +Run against underworld3 `bugfix/multiplier-traction` (PR #617); the +constrained solver's `traction()` is the fix this note prompted. +""" +import sys + +import numpy as np +import sympy + +import underworld3 as uw + +CELL = 0.075 +GAMMA = 10.0 + + +def build(mode, cell=CELL, penalty=1.0e4, gamma=GAMMA): + mesh = uw.meshing.Annulus(radiusInner=0.5, radiusOuter=1.0, cellSize=cell) + x, y = mesh.X + r = sympy.sqrt(x**2 + y**2) + + v = uw.discretisation.MeshVariable("U", mesh, mesh.dim, degree=2) + p = uw.discretisation.MeshVariable("P", mesh, 1, degree=1) + solver_class = (uw.systems.Stokes_Constrained if mode == "constraint" + else uw.systems.Stokes) + stokes = solver_class(mesh, velocityField=v, pressureField=p) + stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel + stokes.constitutive_model.Parameters.shear_viscosity_0 = 1.0 + + # a degree-4 density anomaly: enough structure that the boundary has to work + theta = sympy.atan2(y, x) + stokes.bodyforce = sympy.Matrix( + [sympy.cos(4 * theta) * x / r, sympy.cos(4 * theta) * y / r]) + + # The INNER boundary is no-slip throughout. Two free-slip circles leave + # the rigid rotation unconstrained, and the resulting nullspace is purely + # TANGENTIAL -- so a radial leak metric reads zero on a diverged solve and + # cannot tell a working constraint from a broken one. Measured: the + # unconstrained control diverged with |u| = 2.7e5 and still reported a + # radial leak of 2e-14. + stokes.add_dirichlet_bc((0.0, 0.0), "Lower") + + for boundary in ("Upper",): + if mode == "free": + continue # the control: outer boundary left natural + if mode == "constraint": + # A multiplier field h coupled into the saddle-point system, so the + # constraint is a ROW of the system rather than a term added to + # one. At convergence h on the boundary is the normal traction, + # which is why this one is also a way of getting the stress. + stokes.add_constraint_bc(0.0, boundary) + elif mode == "rotated": + stokes.add_rotated_freeslip_bc(0.0, boundary) + elif mode == "nitsche": + stokes.add_nitsche_bc(0.0, boundary, gamma=gamma, theta=1) + elif mode == "penalty": + # A DIRECT penalty: a boundary traction opposing normal flow, and + # nothing else -- no consistency term, which is exactly what leaves + # it consistent only in the limit. The documented form, from + # docs/advanced/curved-boundary-conditions.md, uses the + # quadrature-point facet normal mesh.Gamma and a POSITIVE + # coefficient. A negative one is anti-damping and the linear solve + # fails immediately, which is how this was got wrong the first time. + # + # READ THIS COLUMN WITH stress.py BESIDE IT. Imposed facet by facet + # on a curved boundary, this constraint LOCKS: the leak falls + # because the boundary is being frozen, not because the condition is + # being satisfied in the way that was meant. At cell 0.075 the + # velocity field here differs from the rotated one by 20% in l2 at + # a coefficient of 1e4, and stress.py measures the same thing + # against an exact answer. + G = mesh.Gamma + stokes.add_natural_bc(penalty * G.dot(v.sym) * G, boundary) + elif mode == "penalty_node": + # The same penalty against the measure-weighted NODE normal, which + # is one direction per node rather than one per facet, and does not + # lock. The only difference between this and the line above is which + # normal. + G = mesh.boundary_normal(boundary) + stokes.add_natural_bc(penalty * G.dot(v.sym) * G, boundary) + else: + raise ValueError(mode) + return mesh, stokes, v + + +def converged(stokes): + """A diverged solve still leaves numbers in the array, and they look like + measurements. Two runs in the first parameter sweep here had failed the + line search and were about to be tabulated.""" + return stokes.snes.getConvergedReason() > 0 + + +def leaks(mesh, v): + """max |u.n| on the outer boundary, against the facet normal and against + the true radial direction, both normalised by the flow speed.""" + coords = v.coords + rad = np.linalg.norm(coords, axis=1) + on_outer = np.abs(rad - 1.0) < 1.0e-6 + # squeeze: a vector MeshVariable's .array is (N, 1, dim), and the middle + # axis broadcasts SILENTLY against an (N, dim) array of normals, giving a + # projection of ~1e-16 for a velocity of ~1e-2. It does not raise. + allu = np.squeeze(np.asarray(v.array)) + u = allu[on_outer] + xy = coords[on_outer] + speed = np.linalg.norm(allu, axis=1).max() + + # true normal of the circle the mesh approximates + n_true = xy / np.linalg.norm(xy, axis=1)[:, None] + leak_true = np.abs((u * n_true).sum(axis=1)).max() / speed + return leak_true, on_outer.sum(), speed + + +def sweep(cells=(0.15, 0.10, 0.075, 0.05), + modes=("penalty", "penalty_node", "nitsche", "constraint", "rotated")): + """Does the leak fall with the mesh, or is it already at round-off? + + A weakly imposed constraint is satisfied to the accuracy of the + DISCRETISATION, so its leak should fall as the mesh is refined. A strongly + imposed one is satisfied to the accuracy of the ARITHMETIC, so its leak + should sit at round-off and stay there. That difference is the claim, and + the refinement is what tells them apart -- a single resolution cannot. + """ + print("| cell size | %s |" % " | ".join(m for m in modes)) + print("|---|" + "---|" * len(modes)) + for cell in cells: + row = [] + for mode in modes: + mesh, stokes, v = build(mode, cell=cell) + stokes.solve() + row.append(("%.2e" % leaks(mesh, v)[0]) if converged(stokes) + else "diverged") + print("| %.3f | %s |" % (cell, " | ".join(row))) + + +def parameter_sweep(): + """The distinction between a direct penalty and Nitsche, measured. + + Both are bounded above by conditioning: the penalty stops improving past + 1e4 and fails at 1e6, and Nitsche's line search fails from gamma = 1e4. + Neither escapes tuning. What differs is the floor each reaches first -- + 1e-3 for the penalty, 3e-5 for Nitsche -- and that Nitsche is ALSO bounded + below, at gamma = 1, where the form stops being coercive. Its usable window + has a threshold at each end, and gamma = 10 sits in the middle of it on any + mesh because gamma is dimensionless and the term already carries mu/h. + """ + print("\ndirect penalty (FACET normal): leak against penalty magnitude") + print("\n| penalty | leak/|u| |") + print("|---|---|") + for pen in (1.0e2, 1.0e3, 1.0e4, 1.0e5, 1.0e6): + mesh, stokes, v = build("penalty", penalty=pen) + stokes.solve() + cell = ("%.2e" % leaks(mesh, v)[0]) if converged(stokes) else "diverged" + print("| %.0e | %s |" % (pen, cell)) + + print("\ndirect penalty (NODE normal): leak against penalty magnitude") + print("\n| penalty | leak/|u| |") + print("|---|---|") + for pen in (1.0e2, 1.0e3, 1.0e4, 1.0e5, 1.0e6): + mesh, stokes, v = build("penalty_node", penalty=pen) + stokes.solve() + cell = ("%.2e" % leaks(mesh, v)[0]) if converged(stokes) else "diverged" + print("| %.0e | %s |" % (pen, cell)) + + print("\nNitsche: leak against gamma") + print("\n| gamma | leak/|u| |") + print("|---|---|") + for g in (1.0, 10.0, 100.0, 1000.0, 1.0e4, 1.0e5): + mesh, stokes, v = build("nitsche", gamma=g) + stokes.solve() + cell = ("%.2e" % leaks(mesh, v)[0]) if converged(stokes) else "diverged" + print("| %g | %s |" % (g, cell)) + + +if __name__ == "__main__": + if sys.argv[1:2] == ["sweep"]: + sweep() + elif sys.argv[1:2] == ["params"]: + parameter_sweep() + else: + modes = sys.argv[1:] or ["free", "penalty", "penalty_node", "nitsche", + "constraint", "rotated"] + print("%-9s %10s %10s %8s" % ("mode", "leak/|u|", "|u|max", "nodes")) + for mode in modes: + mesh, stokes, v = build(mode) + stokes.solve() + lk, n, speed = leaks(mesh, v) + print("%-9s %10.3e %10.3e %8d" % (mode, lk, speed, n)) diff --git a/articles/boundary-conditions-on-non-planar-boundaries/examples/rotated-basis-data.json b/articles/boundary-conditions-on-non-planar-boundaries/examples/rotated-basis-data.json new file mode 100644 index 0000000..76c066e --- /dev/null +++ b/articles/boundary-conditions-on-non-planar-boundaries/examples/rotated-basis-data.json @@ -0,0 +1,1011 @@ +{ + "vertices": [ + [ + -3.5, + -2.9 + ], + [ + -2.625, + -2.9 + ], + [ + -1.75, + -2.9 + ], + [ + -0.875, + -2.9 + ], + [ + 0.0, + -2.9 + ], + [ + 0.875, + -2.9 + ], + [ + 1.75, + -2.9 + ], + [ + 2.625, + -2.9 + ], + [ + 3.5, + -2.9 + ], + [ + -3.0625, + -1.5690676390839076 + ], + [ + -2.1875, + -1.4005781953894207 + ], + [ + -1.3125, + -1.4008544079276464 + ], + [ + -0.4375, + -1.5774930208125677 + ], + [ + 0.4375, + -1.744573187635388 + ], + [ + 1.3125, + -1.9201468750186008 + ], + [ + 2.1875, + -1.9061055410007908 + ], + [ + 3.0625, + -1.7488486663272358 + ], + [ + -3.5, + -0.3632396002330607 + ], + [ + -2.625, + -0.06405866884136113 + ], + [ + -1.75, + 0.1666008481044865 + ], + [ + -0.875, + -0.0676029423016371 + ], + [ + 0.0, + -0.4232124172195797 + ], + [ + 0.875, + -0.7747080243853817 + ], + [ + 1.75, + -0.9985423178094506 + ], + [ + 2.625, + -0.7459268346817169 + ], + [ + 3.5, + -0.5126942107107557 + ], + [ + -3.5, + 0.9051405996504092 + ], + [ + -2.625, + 1.3539119967379585 + ], + [ + -1.75, + 1.6999012721567301 + ], + [ + -0.875, + 1.3485955865475447 + ], + [ + 0.0, + 0.8151813741706304 + ], + [ + 0.875, + 0.2879379634219279 + ], + [ + 1.75, + -0.047813476714175795 + ], + [ + 2.625, + 0.331109747977425 + ], + [ + 3.5, + 0.6809586839338668 + ] + ], + "triangles": [ + [ + 31, + 30, + 21 + ], + [ + 30, + 20, + 21 + ], + [ + 26, + 17, + 18 + ], + [ + 27, + 26, + 18 + ], + [ + 24, + 25, + 33 + ], + [ + 25, + 34, + 33 + ], + [ + 20, + 12, + 21 + ], + [ + 12, + 20, + 11 + ], + [ + 16, + 7, + 8 + ], + [ + 25, + 16, + 8 + ], + [ + 16, + 25, + 24 + ], + [ + 32, + 24, + 33 + ], + [ + 20, + 19, + 11 + ], + [ + 27, + 19, + 28 + ], + [ + 19, + 27, + 18 + ], + [ + 29, + 20, + 30 + ], + [ + 19, + 29, + 28 + ], + [ + 29, + 19, + 20 + ], + [ + 17, + 9, + 18 + ], + [ + 9, + 17, + 0 + ], + [ + 13, + 12, + 4 + ], + [ + 5, + 13, + 4 + ], + [ + 13, + 5, + 14 + ], + [ + 12, + 13, + 21 + ], + [ + 12, + 3, + 4 + ], + [ + 3, + 12, + 11 + ], + [ + 2, + 3, + 11 + ], + [ + 16, + 15, + 7 + ], + [ + 15, + 16, + 24 + ], + [ + 5, + 6, + 14 + ], + [ + 15, + 6, + 7 + ], + [ + 6, + 15, + 14 + ], + [ + 32, + 23, + 24 + ], + [ + 23, + 15, + 24 + ], + [ + 15, + 23, + 14 + ], + [ + 9, + 10, + 18 + ], + [ + 10, + 19, + 18 + ], + [ + 19, + 10, + 11 + ], + [ + 10, + 2, + 11 + ], + [ + 1, + 9, + 0 + ], + [ + 10, + 1, + 2 + ], + [ + 1, + 10, + 9 + ], + [ + 22, + 32, + 31 + ], + [ + 22, + 23, + 32 + ], + [ + 22, + 31, + 21 + ], + [ + 13, + 22, + 21 + ], + [ + 22, + 13, + 14 + ], + [ + 23, + 22, + 14 + ] + ], + "surface": [ + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34 + ], + "frames": [ + { + "p": [ + -3.5, + 0.9051405996504092 + ], + "n": [ + -0.3058957249037569, + 0.9520650216690062 + ], + "t": [ + 0.9520650216690062, + 0.3058957249037569 + ] + }, + { + "p": [ + -2.625, + 1.3539119967379585 + ], + "n": [ + -0.5302120052673807, + 0.8478651009862024 + ], + "t": [ + 0.8478651009862024, + 0.5302120052673807 + ] + }, + { + "p": [ + -1.75, + 1.6999012721567301 + ], + "n": [ + 0.0005151017428689071, + 0.9999998673350886 + ], + "t": [ + 0.9999998673350886, + -0.0005151017428689071 + ] + }, + { + "p": [ + -0.875, + 1.3485955865475447 + ], + "n": [ + 0.5426587927926065, + 0.8399532335820077 + ], + "t": [ + 0.8399532335820077, + -0.5426587927926065 + ] + }, + { + "p": [ + 0.0, + 0.8151813741706304 + ], + "n": [ + 0.4836463257494078, + 0.8752635212260921 + ], + "t": [ + 0.8752635212260921, + -0.4836463257494078 + ] + }, + { + "p": [ + 0.875, + 0.2879379634219279 + ], + "n": [ + 0.5429867639410579, + 0.8397412543068359 + ], + "t": [ + 0.8397412543068358, + -0.5429867639410579 + ] + }, + { + "p": [ + 1.75, + -0.047813476714175795 + ], + "n": [ + -0.0574834315887112, + 0.9983464604498711 + ], + "t": [ + 0.9983464604498711, + 0.0574834315887112 + ] + }, + { + "p": [ + 2.625, + 0.331109747977425 + ], + "n": [ + -0.5055403695426544, + 0.8628029524536158 + ], + "t": [ + 0.8628029524536158, + 0.5055403695426544 + ] + }, + { + "p": [ + 3.5, + 0.6809586839338668 + ], + "n": [ + -0.1847046296744634, + 0.982794078012693 + ], + "t": [ + 0.982794078012693, + 0.1847046296744634 + ] + } + ], + "curve": [ + [ + -3.5, + 0.9051405996504089 + ], + [ + -3.441666666666667, + 0.924709919910546 + ], + [ + -3.3833333333333333, + 0.9459569983210258 + ], + [ + -3.325, + 0.9689047102584147 + ], + [ + -3.2666666666666666, + 0.9935569625066398 + ], + [ + -3.2083333333333335, + 1.019896408691709 + ], + [ + -3.15, + 1.0478823531124308 + ], + [ + -3.091666666666667, + 1.077448917340561 + ], + [ + -3.033333333333333, + 1.1085035443807112 + ], + [ + -2.975, + 1.1409259131181626 + ], + [ + -2.9166666666666665, + 1.1745673310574358 + ], + [ + -2.8583333333333334, + 1.2092506658746796 + ], + [ + -2.8, + 1.2447708660876644 + ], + [ + -2.7416666666666667, + 1.280896108319149 + ], + [ + -2.6833333333333336, + 1.31736959344396 + ], + [ + -2.625, + 1.353911996737958 + ], + [ + -2.5666666666666664, + 1.3902245584714161 + ], + [ + -2.5083333333333333, + 1.425992781792527 + ], + [ + -2.45, + 1.4608906848912475 + ], + [ + -2.3916666666666666, + 1.4945855350373591 + ], + [ + -2.333333333333333, + 1.526742973893888 + ], + [ + -2.275, + 1.5570324272567517 + ], + [ + -2.216666666666667, + 1.5851326787631834 + ], + [ + -2.158333333333333, + 1.6107374767717508 + ], + [ + -2.1, + 1.633561037067616 + ], + [ + -2.041666666666667, + 1.6533433016773964 + ], + [ + -1.9833333333333334, + 1.6698548161227493 + ], + [ + -1.925, + 1.6829010939636901 + ], + [ + -1.8666666666666667, + 1.6923263483659823 + ], + [ + -1.8083333333333333, + 1.6980164853777744 + ], + [ + -1.75, + 1.6999012721567306 + ], + [ + -1.6916666666666667, + 1.6979556149374022 + ], + [ + -1.6333333333333333, + 1.692199905332323 + ], + [ + -1.575, + 1.682699418789918 + ], + [ + -1.5166666666666666, + 1.6695627748036432 + ], + [ + -1.4583333333333335, + 1.6529394938808053 + ], + [ + -1.4, + 1.6330167104634032 + ], + [ + -1.3416666666666668, + 1.61001512313947 + ], + [ + -1.2833333333333332, + 1.5841842828851125 + ], + [ + -1.225, + 1.5557973361588586 + ], + [ + -1.1666666666666665, + 1.5251453520084297 + ], + [ + -1.1083333333333334, + 1.492531370690367 + ], + [ + -1.0499999999999998, + 1.4582643155616355 + ], + [ + -0.9916666666666667, + 1.4226529102631265 + ], + [ + -0.9333333333333331, + 1.3859997397146477 + ], + [ + -0.875, + 1.348595586547545 + ], + [ + -0.8166666666666664, + 1.3107141647846698 + ], + [ + -0.7583333333333333, + 1.272607360377266 + ], + [ + -0.7000000000000002, + 1.2345010741961506 + ], + [ + -0.6416666666666666, + 1.1965917478187014 + ], + [ + -0.5833333333333335, + 1.1590436364841392 + ], + [ + -0.5249999999999999, + 1.1219868773708934 + ], + [ + -0.4666666666666668, + 1.0855163852548482 + ], + [ + -0.4083333333333332, + 1.0496915919046683 + ], + [ + -0.3500000000000001, + 1.0145370304190584 + ], + [ + -0.2916666666666665, + 0.9800437511642502 + ], + [ + -0.2333333333333334, + 0.9461715419910942 + ], + [ + -0.17499999999999982, + 0.9128519118949112 + ], + [ + -0.1166666666666667, + 0.8799917840856701 + ], + [ + -0.058333333333333126, + 0.8474778314169195 + ], + [ + 0.0, + 0.8151813741706305 + ], + [ + 0.05833333333333357, + 0.7829637472749286 + ], + [ + 0.1166666666666667, + 0.7506820312100971 + ], + [ + 0.17500000000000027, + 0.7181950283314438 + ], + [ + 0.2333333333333334, + 0.6853693544451201 + ], + [ + 0.2916666666666665, + 0.6520855046989421 + ], + [ + 0.3500000000000001, + 0.6182437438110678 + ], + [ + 0.4083333333333332, + 0.5837696640761547 + ], + [ + 0.4666666666666668, + 0.548619251245789 + ], + [ + 0.5250000000000004, + 0.5127832990715608 + ], + [ + 0.583333333333333, + 0.4762910187656371 + ], + [ + 0.6416666666666666, + 0.4392127004940097 + ], + [ + 0.7000000000000002, + 0.4016613007004925 + ], + [ + 0.7583333333333337, + 0.363792851740313 + ], + [ + 0.8166666666666664, + 0.325805618850044 + ], + [ + 0.875, + 0.28793796342192796 + ], + [ + 0.9333333333333336, + 0.2504649100526243 + ], + [ + 0.9916666666666671, + 0.2136934567143398 + ], + [ + 1.0499999999999998, + 0.17795671114661105 + ], + [ + 1.1083333333333334, + 0.14360698042499975 + ], + [ + 1.166666666666667, + 0.11100798268452128 + ], + [ + 1.2249999999999996, + 0.08052638813756408 + ], + [ + 1.2833333333333332, + 0.05252292884049681 + ], + [ + 1.3416666666666668, + 0.02734334129772975 + ], + [ + 1.4000000000000004, + 0.005309421388709379 + ], + [ + 1.458333333333333, + -0.013289523913108559 + ], + [ + 1.5166666666666666, + -0.028204549692437686 + ], + [ + 1.5750000000000002, + -0.03923401019190231 + ], + [ + 1.6333333333333337, + -0.04622932722506001 + ], + [ + 1.6916666666666664, + -0.049099395459559214 + ], + [ + 1.75, + -0.047813476714175684 + ], + [ + 1.8083333333333336, + -0.042402488429535534 + ], + [ + 1.8666666666666671, + -0.032958648592165685 + ], + [ + 1.9249999999999998, + -0.01963349805658543 + ], + [ + 1.9833333333333334, + -0.002634378770072665 + ], + [ + 2.041666666666667, + 0.017780499734667887 + ], + [ + 2.0999999999999996, + 0.04130822544601709 + ], + [ + 2.158333333333333, + 0.06760836083270272 + ], + [ + 2.216666666666667, + 0.09631088206901939 + ], + [ + 2.2750000000000004, + 0.12702461196713855 + ], + [ + 2.333333333333333, + 0.15934587047687743 + ], + [ + 2.3916666666666666, + 0.19286707036237616 + ], + [ + 2.45, + 0.22718500012101506 + ], + [ + 2.5083333333333337, + 0.26190856024390546 + ], + [ + 2.5666666666666664, + 0.2966657509787571 + ], + [ + 2.625, + 0.3311097479774251 + ], + [ + 2.6833333333333336, + 0.36492394452614507 + ], + [ + 2.741666666666667, + 0.39782588332598795 + ], + [ + 2.8, + 0.429570044919817 + ], + [ + 2.8583333333333334, + 0.4599495019114082 + ], + [ + 2.916666666666667, + 0.4887964864025283 + ], + [ + 2.9750000000000005, + 0.5159819512151704 + ], + [ + 3.033333333333333, + 0.5414142324664324 + ], + [ + 3.091666666666667, + 0.5650369413091443 + ], + [ + 3.1500000000000004, + 0.5868262259143289 + ], + [ + 3.208333333333333, + 0.6067875511844959 + ], + [ + 3.2666666666666666, + 0.6249521436990915 + ], + [ + 3.325, + 0.6413732437141221 + ], + [ + 3.3833333333333337, + 0.6561222955694307 + ], + [ + 3.4416666666666664, + 0.6692851936251155 + ], + [ + 3.5, + 0.680958683933867 + ] + ] +} \ No newline at end of file diff --git a/articles/boundary-conditions-on-non-planar-boundaries/examples/rotated-basis.typ b/articles/boundary-conditions-on-non-planar-boundaries/examples/rotated-basis.typ new file mode 100644 index 0000000..3657d85 --- /dev/null +++ b/articles/boundary-conditions-on-non-planar-boundaries/examples/rotated-basis.typ @@ -0,0 +1,186 @@ +// Rotated boundary conditions: where the basis changes, and where in the +// solver the rotation lives. +// +// Thesis: rotating the degrees of freedom leaves the discrete problem in a +// MIXED basis, but the obligation is CONTAINED -- it lives in the velocity +// block and its multigrid substructure, and the Schur/pressure machinery +// wrapping it never handles a rotated vector. +// +// House style follows the cetz-figures skill in the underworld3 repository +// (cetz 0.3.4, hex colours, helper-function pattern). +// +// Build, from this directory: +// +// python3 generate-rotated-basis.py # writes the JSON geometry +// typst compile --format svg rotated-basis.typ ../figures/rotated-basis.svg +// typst compile --ppi 200 rotated-basis.typ ../figures/rotated-basis.png + +#import "@preview/cetz:0.3.4" + +#set page(width: auto, height: auto, margin: 16pt) +#set text(size: 10pt) + +#cetz.canvas({ + import cetz.draw: * + + // Colours + let cart-fg = rgb("#4a7bf7") + let cart-bg = rgb("#dce8fc") + let rot-fg = rgb("#e57373") + let rot-bg = rgb("#fce4ec") + let plain-bg = rgb("#f2f2f0") + let mg-bg = rgb("#fdf1f4") + let schur-fg = rgb("#49a87c") // the un-rotated half + let schur-bg = rgb("#e8f4e8") + let ink = rgb("#1a1a1a") + let muted = rgb("#7a7a7a") + let hair = rgb("#d0d0d0") + + // ====================================================================== + // LEFT: a deformed surface, meshed. Geometry (nodes, Delaunay triangles, + // surface frames) comes from generate-rotated-basis.py via JSON -- the + // skill's rule, and the reason every node is now in the triangulation. + // An earlier version joined nodes by a distance threshold and left some out. + // ====================================================================== + let data = json("rotated-basis-data.json") + let vtx = data.vertices + let at(i) = (vtx.at(i).at(0), vtx.at(i).at(1)) + let is-surface(i) = data.surface.contains(i) + + // triangles first, then the surface, then the nodes: painter's algorithm + for tri in data.triangles { + line(at(tri.at(0)), at(tri.at(1)), at(tri.at(2)), close: true, + stroke: (paint: hair, thickness: 0.55pt)) + } + + line(..data.curve.map(q => (q.at(0), q.at(1))), + stroke: (paint: ink, thickness: 1.2pt)) + + // A rotated frame at every constrained node: n the outward surface normal, + // t the tangent. Both follow the node, which is the whole point. + for (k, f) in data.frames.enumerate() { + let p = (f.p.at(0), f.p.at(1)) + let n = f.n + let tg = f.t + let ln = 0.72 + let lt = 0.46 + line(p, (p.at(0) + ln * n.at(0), p.at(1) + ln * n.at(1)), + mark: (end: ">", scale: 0.4), stroke: (paint: rot-fg, thickness: 1.1pt)) + line(p, (p.at(0) + lt * tg.at(0), p.at(1) + lt * tg.at(1)), + mark: (end: ">", scale: 0.4), stroke: (paint: rot-fg, thickness: 1.1pt)) + if k == 4 { + content((p.at(0) + 1.20 * ln * n.at(0), p.at(1) + 1.20 * ln * n.at(1)), + text(fill: rot-fg, size: 9pt, $n$)) + content((p.at(0) + 1.55 * lt * tg.at(0) + 0.24 * n.at(0), + p.at(1) + 1.55 * lt * tg.at(1) + 0.24 * n.at(1)), + text(fill: rot-fg, size: 9pt, $t$)) + } + } + + // the Cartesian frame at one interior node -- identical at every other one, + // which is what makes the surface the odd one out + let ip = at(12) // an interior node with room around it + line(ip, (ip.at(0) + 0.66, ip.at(1)), mark: (end: ">", scale: 0.4), + stroke: (paint: cart-fg, thickness: 1.1pt)) + line(ip, (ip.at(0), ip.at(1) + 0.66), mark: (end: ">", scale: 0.4), + stroke: (paint: cart-fg, thickness: 1.1pt)) + content((ip.at(0) + 0.88, ip.at(1)), text(fill: cart-fg, size: 9pt, $x$)) + content((ip.at(0), ip.at(1) + 0.88), text(fill: cart-fg, size: 9pt, $y$)) + + for i in range(vtx.len()) { + if is-surface(i) { + circle(at(i), radius: 0.13, fill: rot-fg, + stroke: (paint: rot-fg, thickness: 1pt)) + } else { + circle(at(i), radius: 0.11, fill: cart-bg, + stroke: (paint: cart-fg, thickness: 1pt)) + } + } + + content((0, 3.05), text(weight: "bold", size: 10pt, fill: ink, + "A deformed surface has no preferred direction")) + + circle((-3.3, -3.55), radius: 0.13, fill: rot-fg, stroke: (paint: rot-fg)) + content((-3.05, -3.55), anchor: "west", + text(size: 9pt, fill: ink, [surface node --- solve for $(v_n, v_t)$, hold $v_n$])) + circle((-3.3, -4.1), radius: 0.11, fill: cart-bg, + stroke: (paint: cart-fg, thickness: 1pt)) + content((-3.05, -4.1), anchor: "west", + text(size: 9pt, fill: ink, [interior node --- solve for $(v_x, v_y)$])) + + line((4.35, -4.9), (4.35, 3.3), stroke: (paint: hair, thickness: 0.8pt)) + + // ====================================================================== + // RIGHT: where the rotation lives. Two blocks ABUTTING, not nested: the + // velocity solve is rotated, the Schur/pressure solve is not, and the + // single un-rotation on the boundary between them feeds both the Schur + // solve and everything outside. + // ====================================================================== + let panel(tl, br, title, subtitle, bg, edge) = { + import cetz.draw: * + rect(tl, br, fill: bg, stroke: (paint: edge, thickness: 1pt), radius: 5pt) + content((tl.at(0) + 0.30, tl.at(1) - 0.36), anchor: "west", + text(weight: "bold", size: 9.5pt, fill: edge, title)) + if subtitle != none { + // cetz content() lays out at natural width and spills over the rect. + // Box it to the panel's own width so the text wraps inside the border. + // The canvas default is 1cm per unit, so the arithmetic is direct. + let tw = (br.at(0) - tl.at(0) - 0.60) * 1cm + content((tl.at(0) + 0.30, tl.at(1) - 0.90), anchor: "north-west", + box(width: tw, text(size: 8.5pt, fill: muted, subtitle))) + } + } + + content((10.3, 3.05), text(weight: "bold", size: 10pt, fill: ink, + "Where the rotation lives")) + + // -- the rotated half --------------------------------------------------- + panel((4.9, 1.65), (11.75, -3.70), [Velocity solve --- rotated], + [$hat(A) = Q^T A Q$, #h(0.7em) $hat(b) = Q^T b$, #h(0.7em) + $v_n$ held strongly at surface nodes], rot-bg, rot-fg) + + panel((5.35, -0.15), (11.50, -3.25), "Multigrid", + [the transfers are the only further obligation], mg-bg, rot-fg) + + let mgrow(y, lhs, rhs) = { + import cetz.draw: * + content((5.65, y), anchor: "west", text(size: 9pt, fill: ink, lhs)) + content((8.05, y), anchor: "west", text(size: 9pt, fill: rot-fg, rhs)) + } + mgrow(-1.85, [prolongation], [$P -> Q^T P$]) + mgrow(-2.40, [coarse operators], [inherit $Q$ via $R A P$]) + mgrow(-2.93, [coarse solve], [SVD (rigid rotations)]) + + // -- the un-rotated half, abutting -------------------------------------- + panel((13.35, 1.65), (17.85, -1.35), [Fieldsplit / Schur solve], + [pressure and constraints. Isotropic, and carrying no boundary + condition of this kind, so it never sees a rotated vector.], + schur-bg, schur-fg) + + // -- one un-rotation on the boundary, feeding both ---------------------- + // The junction sits in the gap BETWEEN the two blocks, low enough to clear + // the velocity block's own subtitle -- one un-rotation, two consumers. + let jx = 12.55 + let jy = -0.40 + line((11.75, jy), (jx, jy), stroke: (paint: rot-fg, thickness: 1pt)) + circle((jx, jy), radius: 0.075, fill: rot-fg, stroke: (paint: rot-fg)) + content((jx, jy + 0.45), text(size: 9pt, fill: rot-fg, $v = Q hat(v)$)) + + // branch 1: into the Schur solve, which needs it un-rotated + line((jx, jy), (13.29, jy), mark: (end: ">", scale: 0.4), + stroke: (paint: rot-fg, thickness: 1pt)) + + // branch 2: out to everything else + line((jx, jy), (jx, -2.70), stroke: (paint: rot-fg, thickness: 1pt)) + line((jx, -2.70), (13.20, -2.70), mark: (end: ">", scale: 0.4), + stroke: (paint: rot-fg, thickness: 1pt)) + content((13.33, -2.70), anchor: "west", text(size: 8.5pt, fill: rot-fg, + [and to everything outside ---\ output, advection, the surface update])) + + // convention, stated -- a reader who assumes the transpose reads the + // whole figure backwards + line((4.75, -5.05), (18.1, -5.05), stroke: (paint: hair, thickness: 0.8pt)) + content((4.75, -5.55), anchor: "west", text(size: 9pt, fill: ink, + [Convention: the columns of $Q$ are the nodal frame, so $hat(v) = Q^T v$, + and $Q = I$ at every unconstrained node.])) +}) diff --git a/articles/boundary-conditions-on-non-planar-boundaries/examples/solcx.py b/articles/boundary-conditions-on-non-planar-boundaries/examples/solcx.py new file mode 100644 index 0000000..89b2660 --- /dev/null +++ b/articles/boundary-conditions-on-non-planar-boundaries/examples/solcx.py @@ -0,0 +1,291 @@ +"""The other half of the stress test: a lateral viscosity contrast. + +`stress.py` is the geometry half -- a curved boundary with the rheology +trivial. This is the rheology half, with the geometry trivial. No exact +solution has both, so the two halves are separate tests and the note does not +conflate them. + +On a box, "no flow through this wall" is a single velocity component and every +treatment here reduces to the same constraint. Nothing about this test is about +normals. What it is about is whether a WEAKLY imposed constraint holds the +degrees of freedom it was given when the viscosity beside them jumps by six +orders of magnitude -- and SolCx is the only case of the two halves with an +exact surface stress to check that against. + +SolCx: unit box, viscosity eta_A left of x = 0.5 and eta_B right of it, +forcing (0, cos(pi.x) sin(pi.z)), free slip on all four walls. +`uw.analytic.SolCx` publishes the exact stress, and `topography_top` is +-sigma_zz on the top boundary, which is the quantity here. + +Three walls carry the ordinary component Dirichlet condition. The treatment +under test is on the TOP wall only, so what is measured is attributable to it. + +The metrics +----------- + * the leak, max |u.n| / |u|, reported SEPARATELY over the soft half of the + wall and the stiff half. One coefficient has to hold both, and whether it + can is the whole question; + * the surface stress: relative l2 error of sigma_zz along the top wall + against the exact one, both mean-removed (the box is enclosed, so the + pressure -- and with it the level of sigma_zz -- is fixed only up to a + constant). + + python3 solcx.py sweep # resolution, each treatment + python3 solcx.py contrast # the viscosity ratio, each treatment + python3 solcx.py params # the penalty coefficient, both halves + python3 solcx.py control # the top wall left free + +Run against underworld3 `bugfix/multiplier-traction` (PR #617); the +constrained solver's `traction()` is the fix this note prompted. +""" +import sys + +import numpy as np +import sympy + +import underworld3 as uw + +from underworld3.utilities.boundary_flux import _boundary_field_nodes + +RES = 32 +ETA_B = 1.0e6 +PENALTY = 1.0e4 +GAMMA = 10.0 + + +def build(mode, res=RES, eta_B=ETA_B, penalty=PENALTY, gamma=GAMMA): + mesh = uw.meshing.StructuredQuadBox( + elementRes=(res, res), minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), qdegree=3) + + v = uw.discretisation.MeshVariable("U", mesh, mesh.dim, degree=2) + p = uw.discretisation.MeshVariable("P", mesh, 1, degree=1) + solver_class = (uw.systems.Stokes_Constrained if mode == "constraint" + else uw.systems.Stokes) + stokes = solver_class(mesh, velocityField=v, pressureField=p) + + exact = uw.analytic.SolCx(mesh, eta_A=1.0, eta_B=eta_B, x_c=0.5, n=1) + stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel + stokes.constitutive_model.Parameters.shear_viscosity_0 = exact.fn_viscosity + if mode != "constraint": + # Stokes_Constrained builds its own Schur preconditioner from the + # operator's blocks and refuses this assignment. + stokes.saddle_preconditioner = 1.0 / exact.fn_viscosity + stokes.bodyforce = exact.fn_bodyforce + stokes.tolerance = 1.0e-9 + # Enclosed: the constant pressure mode is a nullspace and a direct solve on + # the singular saddle returns a quiet, wrong answer without this. With the + # top wall left free (the control) the domain is open and there is no such + # mode -- asserting one there is what makes the control fail to solve + # rather than fail to hold the boundary. + stokes.petsc_use_pressure_nullspace = (mode != "free") + + stokes.add_dirichlet_bc((0.0, None), "Left") + stokes.add_dirichlet_bc((0.0, None), "Right") + stokes.add_dirichlet_bc((None, 0.0), "Bottom") + + if mode == "dirichlet": + # What a box lets you do, and the reference the others are judged by. + stokes.add_dirichlet_bc((None, 0.0), "Top") + elif mode == "constraint": + stokes.add_constraint_bc(0.0, "Top") + elif mode == "rotated": + stokes.add_rotated_freeslip_bc(0.0, "Top") + elif mode == "nitsche": + stokes.add_nitsche_bc(0.0, "Top", gamma=gamma, theta=1) + elif mode == "penalty": + # A CONSTANT coefficient, deliberately. Scaling it by the local + # viscosity is the fair thing to want here -- one number cannot be large + # against 1e6 and moderate against 1 -- and it does not solve: a + # Piecewise viscosity inside the boundary term fails the line search at + # every magnitude tried, from 1 to 1e3 times mu, against both normals. + # A constant 1e4 solves, and that is what this column is. + stokes.add_natural_bc(penalty * mesh.Gamma.dot(v.sym) * mesh.Gamma, "Top") + elif mode == "free": + pass + else: + raise ValueError(mode) + + return mesh, stokes, v, exact + + +def converged(stokes): + return stokes.snes.getConvergedReason() > 0 + + +def trace(solver, field_id, var, boundary="Top"): + """(coords, values) for `var` at the nodes it carries on `boundary`, + selected by the mesh boundary LABEL.""" + nodes, *_ = _boundary_field_nodes(solver, boundary, field_id) + coords = np.array([node[2] for node in nodes]) + tree = uw.kdtree.KDTree(np.ascontiguousarray(var.coords)) + index = np.asarray(tree.query(np.ascontiguousarray(coords), 1)[1]).flatten() + return coords, np.squeeze(np.asarray(var.array))[index] + + +def recovered_traction(mesh, stokes): + """sigma_zz on the top wall, projected out of the solved fields.""" + field = uw.discretisation.MeshVariable("Szz", mesh, 1, degree=2) + projection = uw.systems.Projection(mesh, field) + projection.uw_function = stokes.stress[1, 1] + projection.solve() + return trace(projection, 0, field) + + +def reaction_traction(stokes, mode): + """The traction the solve returned, for the two methods that return one.""" + if mode == "rotated": + return stokes.boundary_normal_traction("Top") + if mode == "constraint": + # `traction()` is h + r(u.n - g), the WHOLE boundary term. Reading the + # multiplier alone here was wrong by an order of magnitude and a sign at + # a 1e6 viscosity contrast, because the default r is viscosity-weighted + # (underworld3#607, fixed in #617). The box is flat, so evaluating the + # expression at the trace nodes is safe -- on a convex curved boundary it + # would extrapolate (#605), which is why stress.py reads arrays instead. + coords, _h = trace(stokes, 2, stokes.multiplier("Top")) + return coords, np.asarray( + uw.function.evaluate(stokes.traction("Top"), coords)).reshape(-1) + return None + + +def stress_error(coords, values, exact, trim=0.0): + """Relative l2 error along the wall, both mean-removed. + + The mean is the gauge: the box is enclosed, so the pressure and with it the + level of sigma_zz is fixed only up to a constant. The deviation is what + topography is built from and the only part that is determined. + + `trim` drops nodes within that distance of the two ends of the wall. The + corners are where the treatment under test meets the side walls' component + conditions, so a node there is constrained twice and by two different + mechanisms; trimming separates what the wall does from what the corner does. + """ + coords = np.asarray(coords) + values = np.asarray(values) + if trim > 0.0: + keep = (coords[:, 0] > trim) & (coords[:, 0] < 1.0 - trim) + coords, values = coords[keep], values[keep] + truth = -exact.topography_top(coords) # topography_top is -sigma_zz + order = np.argsort(coords[:, 0]) + got = values[order] - values.mean() + truth = truth[order] - truth.mean() + return float(np.linalg.norm(got - truth) / np.linalg.norm(truth)) + + +def leaks(v): + """max |u_z| / |u| on the top wall, over the soft half and the stiff half. + + Reported separately because one penalty coefficient has to hold both, and + the two sides do not ask the same thing of it. + """ + coords = v.coords + top = np.abs(coords[:, 1] - 1.0) < 1.0e-9 + u = np.squeeze(np.asarray(v.array)) + speed = np.linalg.norm(u, axis=1).max() + out = [] + for side in (coords[:, 0] < 0.5, coords[:, 0] > 0.5): + mask = top & side + out.append(np.abs(u[mask, 1]).max() / speed) + return out + + +def measure(mode, **kwargs): + mesh, stokes, v, exact = build(mode, **kwargs) + stokes.solve() + if not converged(stokes): + return None + soft, stiff = leaks(v) + out = {"soft": soft, "stiff": stiff, "velocity": exact.velocity_error(v)} + coords, values = recovered_traction(mesh, stokes) + out["recovered"] = stress_error(coords, values, exact) + out["trimmed"] = stress_error(coords, values, exact, trim=2.0 / kwargs.get("res", RES)) + read = reaction_traction(stokes, mode) + if read is not None: + coords, values = read + # The reaction is the traction holding the wall, opposite in sign to + # sigma_zz -- the same convention as in stress.py. + out["reaction"] = stress_error(coords, -np.asarray(values), exact) + return out + + +MODES = ("dirichlet", "penalty", "nitsche", "constraint", "rotated") + + +def sweep(resolutions=(16, 32, 64), modes=MODES): + print("surface stress error, eta_B/eta_A = %.0e" % ETA_B) + print() + print("| elements | " + " | ".join(modes) + " | rotated (reaction) | multiplier |") + print("|---" * (len(modes) + 3) + "|") + for res in resolutions: + row, extra = [], {} + for mode in modes: + got = measure(mode, res=res) + row.append("diverged" if got is None else "%.2e" % got["recovered"]) + if got and "reaction" in got: + extra[mode] = "%.2e" % got["reaction"] + print("| %d | %s | %s | %s |" + % (res, " | ".join(row), extra.get("rotated", "-"), + extra.get("constraint", "-")), flush=True) + + +def contrast(ratios=(1.0e1, 1.0e2, 1.0e3, 1.0e4, 1.0e6), modes=MODES): + """Does the treatment survive the viscosity jump getting bigger? + + `dirichlet` is the control, and it is the one to read first. It is the + ordinary component condition a box allows, it holds u.n exactly, and its + velocity error is 1e-5 at every contrast here -- so whatever it reads in + the stress column is the RECOVERY's error and not a boundary condition's. + A treatment can only be said to be worse than the reference where it is + worse than that. + """ + print("whole wall / trimmed by two elements at each end") + print() + print("| eta_B/eta_A | " + " | ".join(modes) + " |") + print("|---" * (len(modes) + 1) + "|") + for ratio in ratios: + row = [] + for mode in modes: + got = measure(mode, eta_B=ratio) + row.append("diverged" if got is None + else "%.2e / %.2e" % (got["recovered"], got["trimmed"])) + print("| %.0e | %s |" % (ratio, " | ".join(row)), flush=True) + + +def parameters(): + """The leak on each half of the wall, against the parameter. + + A dimensionless gamma carries mu/h with it and so asks the same of both + halves. A bare penalty coefficient does not, and the stiff half is where + that shows. + """ + for mode, values, label in ( + ("penalty", (1e2, 1e4, 1e6, 1e8), "penalty coefficient"), + ("nitsche", (1.0, 10.0, 100.0, 1000.0), "Nitsche gamma")): + print("\n%s, eta_B/eta_A = %.0e" % (label, ETA_B)) + print("\n| %s | leak, soft half | leak, stiff half | stress error |" % label) + print("|---|---|---|---|") + for value in values: + kwargs = {"gamma": value} if mode == "nitsche" else {"penalty": value} + got = measure(mode, **kwargs) + if got is None: + print("| %g | diverged | | |" % value) + continue + print("| %g | %.2e | %.2e | %.2e |" + % (value, got["soft"], got["stiff"], got["recovered"]), flush=True) + + +def control(): + for mode in ("free", "dirichlet"): + got = measure(mode) + if got is None: + print("%-10s diverged" % mode, flush=True) + continue + print("%-10s leak soft %.2e stiff %.2e velocity %.2e stress %.2e" + % (mode, got["soft"], got["stiff"], got["velocity"], + got["recovered"]), flush=True) + + +if __name__ == "__main__": + command = sys.argv[1:2] or ["sweep"] + {"sweep": sweep, "contrast": contrast, + "params": parameters, "control": control}[command[0]]() diff --git a/articles/boundary-conditions-on-non-planar-boundaries/examples/stress.py b/articles/boundary-conditions-on-non-planar-boundaries/examples/stress.py new file mode 100644 index 0000000..977dc8a --- /dev/null +++ b/articles/boundary-conditions-on-non-planar-boundaries/examples/stress.py @@ -0,0 +1,422 @@ +"""Does the surface stress come out right? + +`leak.py` measures the CONSTRAINT -- how much flow each boundary treatment lets +through. This measures the thing the constraint is wanted for: the wall-normal +stress on the boundary, which is dynamic topography once divided by the +buoyancy scale. Against an exact answer, so it is a measurement rather than a +comparison of two methods that might both be wrong. + +The oracle +---------- +Kramer, Davies & Wilson (2021) give exact Stokes solutions in a cylindrical +annulus, and their `assess` package publishes the RADIAL STRESS directly -- +`radial_stress = tau_rr - p` -- which is the quantity here. Underworld wraps +the package as `uw.analytic.CylindricalStokes`. + +The case is the smooth one: density (r/r_o)^k cos(n.theta) driving the flow, +free slip on both radii, viscosity 1. On the outer boundary the exact +sigma_rr is a pure cos(n.theta) (checked, residual 1e-16), so the measurement +is the amplitude of that harmonic and the metric is its relative error. + +The inner boundary carries the EXACT velocity as a Dirichlet condition rather +than a free-slip treatment of its own. The exact solution satisfies both, so +the problem is unchanged; what it buys is that the treatment under test is the +only free-slip condition in the model, and that the rigid-rotation nullspace of +two free-slip circles is gone. + +What is compared +---------------- +Two routes to the surface stress, and the difference between them is the +note's argument: + + * RECOVERED -- project r.sigma.r out of the solved velocity and pressure. + Available for every treatment, and the only route the weak ones have. + * REACTION -- the constraint reaction of the rotated method + (`boundary_normal_traction`), and the multiplier field of the constraint + method. Not recovered from the solution: an unknown the solve returned. + +Both are compared against the same exact amplitude, using the TRUE radial +direction, so no treatment is being scored against its own normal. + +Sign: `boundary_normal_traction` and the multiplier both return the constraint +reaction, which is opposite in sign to sigma_rr as `assess` publishes it (it is +the traction that holds the boundary, not the traction the fluid exerts). The +dynamic-topography formula h = -(sigma_nn - mean)/(rho.g) carries the sign +back. Amplitudes are compared unsigned and the sign is printed so the +convention stays visible. + + python3 stress.py sweep # the refinement table + python3 stress.py params # penalty coefficient and Nitsche gamma + python3 stress.py locking # the facet normal does not converge + python3 stress.py control # the metric fires when the BC is removed + +Run against underworld3 `bugfix/multiplier-traction` (PR #617); the +constrained solver's `traction()` is the fix this note prompted. +""" +import sys + +import numpy as np +import sympy + +import underworld3 as uw + +# The boundary trace of a field, selected by the mesh boundary LABEL. An earlier +# version of this comparison selected it by a radius band that narrowed with the +# mesh, which quietly admitted a different node set at each resolution. This is +# the same selector the solver's own reaction recovery uses. +from underworld3.utilities.boundary_flux import _boundary_field_nodes + +N = 2 # azimuthal wavenumber of the density anomaly +K = 3 # its radial power +R_I, R_O = 0.5, 1.0 +CELL = 0.075 +PENALTY = 1.0e4 +GAMMA = 10.0 + + +def build(mode, cell=CELL, penalty=PENALTY, gamma=GAMMA): + """The annulus, the forcing, and one boundary treatment on the outer arc.""" + mesh = uw.meshing.Annulus(radiusInner=R_I, radiusOuter=R_O, cellSize=cell) + x, y = mesh.X + r = sympy.sqrt(x**2 + y**2) + theta = sympy.atan2(y, x) + + v = uw.discretisation.MeshVariable("U", mesh, mesh.dim, degree=2) + p = uw.discretisation.MeshVariable("P", mesh, 1, degree=1) + solver_class = (uw.systems.Stokes_Constrained if mode == "constraint" + else uw.systems.Stokes) + stokes = solver_class(mesh, velocityField=v, pressureField=p) + stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel + stokes.constitutive_model.Parameters.shear_viscosity_0 = 1.0 + + # The forcing `assess` solves for: rho = (r/r_o)^k cos(n.theta), gravity + # inward. This is the convention the Kramer benchmark in underworld3's + # docs/examples uses, and the velocity error below is what checks it. + rho = ((r / R_O) ** K) * sympy.cos(N * theta) + stokes.bodyforce = -rho * sympy.Matrix([[x / r, y / r]]) + + exact = uw.analytic.CylindricalStokes( + mesh, n=N, k=K, r_inner=R_I, r_outer=R_O, + density="smooth", boundary="free") + + v_exact = uw.discretisation.MeshVariable("Uex", mesh, mesh.dim, degree=2) + v_exact.data[:] = exact.evaluate("velocity", v_exact.coords) + stokes.add_dirichlet_bc(v_exact.sym, "Lower") + + if mode == "constraint": + stokes.add_constraint_bc(0.0, "Upper") + elif mode == "rotated": + stokes.add_rotated_freeslip_bc(0.0, "Upper") + elif mode == "nitsche": + # Nitsche's default normal is mesh.boundary_normal(boundary) -- the + # measure-weighted node normal, not the per-facet one. + stokes.add_nitsche_bc(0.0, "Upper", gamma=gamma, theta=1) + elif mode == "penalty": + # The documented direct penalty, with the quadrature-point FACET normal. + stokes.add_natural_bc(penalty * mesh.Gamma.dot(v.sym) * mesh.Gamma, "Upper") + elif mode == "penalty_node": + # The same penalty against the measure-weighted NODE normal. The only + # difference between this and the line above is which normal, and it is + # the difference between a method that converges and one that locks. + node_n = mesh.boundary_normal("Upper") + stokes.add_natural_bc(penalty * node_n.dot(v.sym) * node_n, "Upper") + elif mode == "free": + pass # the control: no condition on the outer arc + else: + raise ValueError(mode) + + return mesh, stokes, v, exact + + +def converged(stokes): + """A diverged solve leaves numbers in the array that look like measurements.""" + return stokes.snes.getConvergedReason() > 0 + + +def exact_amplitude(exact): + """Amplitude of cos(n.theta) in the exact sigma_rr on the outer boundary. + + Sampled densely on the true circle and least-squares fitted. The residual + is returned too: it is 1e-16, which is what says the exact surface stress + really is a single harmonic and the amplitude is the whole of it. + """ + phi = np.linspace(0.0, 2.0 * np.pi, 720, endpoint=False) + points = np.c_[R_O * np.cos(phi), R_O * np.sin(phi)] + sigma = np.array([exact._above.radial_stress_cartesian(pt) for pt in points]) + (mean, c, s), residual = _fit(phi, sigma) + return c, residual + + +def _fit(angles, values): + """Least squares fit of a constant plus the degree-N harmonic. + + A least-squares fit rather than a quadrature: boundary nodes are not evenly + spaced in theta, and on a P2 trace vertices and edge midpoints alternate + with unequal gaps. Returns ((mean, cos, sin), max residual). + """ + basis = np.c_[np.ones_like(angles), np.cos(N * angles), np.sin(N * angles)] + coefficients, *_ = np.linalg.lstsq(basis, values, rcond=None) + residual = np.abs(values - basis @ coefficients).max() + return coefficients, residual + + +def trace(solver, field_id, var, boundary="Upper"): + """(coords, values) for `var` at the nodes it carries on `boundary`.""" + nodes, *_ = _boundary_field_nodes(solver, boundary, field_id) + coords = np.array([node[2] for node in nodes]) + tree = uw.kdtree.KDTree(np.ascontiguousarray(var.coords)) + index = np.asarray(tree.query(np.ascontiguousarray(coords), 1)[1]).flatten() + # squeeze: a MeshVariable's .array carries a middle axis that broadcasts + # silently against an (N,) index -- see leak.py. + return coords, np.squeeze(np.asarray(var.array))[index] + + +def recovered_traction(mesh, stokes): + """sigma_rr on the outer boundary, projected out of the solved fields. + + The recovery every treatment can do, and the only one the weak forms have. + Against the TRUE radial direction, which is also the direction the oracle + publishes, so the comparison does not depend on the solver's normal. + """ + x, y = mesh.X + r = sympy.sqrt(x**2 + y**2) + radial = sympy.Matrix([[x / r, y / r]]) + sigma_rr = (radial * stokes.stress * radial.T)[0, 0] + + field = uw.discretisation.MeshVariable("Srr", mesh, 1, degree=2) + projection = uw.systems.Projection(mesh, field) + projection.uw_function = sigma_rr + projection.solve() + return trace(projection, 0, field) + + +# The augmented-Lagrangian parameter this problem gets by default: the base +# (1e4) times the local viscosity, which is 1 here. +AUGMENTATION = 1.0e4 + + +def multiplier_traction(stokes, v, boundary="Upper"): + """The WHOLE traction a multiplier constraint holds the boundary with. + + The momentum row carries `h + r(u.n - g)`, so `h` alone is short by `r` times + the discrete constraint residual. `stokes.traction(boundary)` is that sum as + an expression; this reads it off the node arrays instead, because evaluating + an expression at points sitting exactly on a convex curved boundary + extrapolates from the containing cell (underworld3#605) and that error would + land on top of the measurement. + + `u.n` is taken against the true radial direction rather than the constraint's + node normal. The two differ at O(h^2) and they multiply a term that is itself + a correction. + """ + nodes, *_ = _boundary_field_nodes(stokes, boundary, 2) + coords = np.array([node[2] for node in nodes]) + # The multiplier is carried at the VELOCITY degree, so h and u share a node + # set and one lookup serves both. Building it is the only part of this that + # is not arithmetic, and it depends on the mesh rather than on the solution: + # a time-stepping consumer builds it once and reuses it every step. + index = _trace_index(v, coords) + h = np.squeeze(np.asarray(stokes.multiplier(boundary).array))[index] + u = np.squeeze(np.asarray(v.array))[index] + normal = coords / np.linalg.norm(coords, axis=1)[:, None] + return coords, h + AUGMENTATION * (u * normal).sum(axis=1) + + +_TRACE_INDEX = {} + + +def _trace_index(var, coords): + """Row of `var`'s arrays for each trace coordinate, cached per (mesh, trace).""" + key = (id(var), coords.shape[0], float(coords.sum())) + if key not in _TRACE_INDEX: + tree = uw.kdtree.KDTree(np.ascontiguousarray(var.coords)) + _TRACE_INDEX[key] = np.asarray( + tree.query(np.ascontiguousarray(coords), 1)[1]).flatten() + return _TRACE_INDEX[key] + + +def reaction_traction(stokes, mode, v=None): + """The constraint reaction, for the two methods that return one.""" + if mode == "rotated": + return stokes.boundary_normal_traction("Upper") + if mode == "constraint": + return multiplier_traction(stokes, v) + return None + + +def amplitude_error(coords, values, reference): + """Relative error in the harmonic amplitude, unsigned, and the sign.""" + angles = np.arctan2(coords[:, 1], coords[:, 0]) + (_mean, c, _s), _residual = _fit(angles, values) + return abs(abs(c) - abs(reference)) / abs(reference), np.sign(c) + + +def split(coords): + """Vertices from edge midpoints. A vertex of the annulus mesh sits exactly + on the circle; a P2 edge midpoint sits on the chord, inside it by the + sagitta. They are not interchangeable: vertex values of sigma_nn carry the + O(h) facet-geometry error and midpoint values are superconvergent + (underworld3#414).""" + on_circle = np.abs(np.linalg.norm(coords, axis=1) - R_O) < 1.0e-9 + return {"vertex": on_circle, "midpoint": ~on_circle} + + +def leak(v): + """max |u.n| on the outer boundary against the TRUE radial direction, + normalised by the flow speed -- the measurement leak.py tabulates.""" + coords = v.coords + radius = np.linalg.norm(coords, axis=1) + outer = np.abs(radius - R_O) < 1.0e-6 + u = np.squeeze(np.asarray(v.array)) + normal = coords[outer] / radius[outer][:, None] + return np.abs((u[outer] * normal).sum(axis=1)).max() / np.linalg.norm(u, axis=1).max() + + +def measure(mode, cell=CELL, **kwargs): + """One solve, and everything read off it.""" + mesh, stokes, v, exact = build(mode, cell=cell, **kwargs) + stokes.solve() + if not converged(stokes): + return None + reference, _residual = exact_amplitude(exact) + out = { + "leak": leak(v), + "velocity": exact.error("velocity", v), + "exact": reference, + } + coords, values = recovered_traction(mesh, stokes) + out["recovered"], out["sign"] = amplitude_error(coords, values, reference) + read = reaction_traction(stokes, mode, v=v) + if read is not None: + coords, values = read + out["reaction"], out["reaction_sign"] = amplitude_error(coords, values, reference) + for name, mask in split(coords).items(): + out["reaction_" + name] = amplitude_error( + coords[mask], values[mask], reference)[0] + return out + + +MODES = ("penalty_node", "nitsche", "constraint", "rotated") + + +def both(cells=(0.15, 0.10, 0.075, 0.05), modes=MODES): + """The constraint and the stress, from the SAME solves. + + The two questions -- does the boundary hold, and is the answer right -- are + one experiment. Reporting them from separate runs invited the reader to + compare numbers that came from different problems. + """ + exact, _residual = exact_amplitude(build("free", cell=0.2)[3]) + print("exact sigma_rr on r = %.2f: %.10f cos(%d.theta)" % (R_O, exact, N)) + print() + print("leak / stress error, from one solve each") + print() + print("| cell size | " + " | ".join(modes) + " |") + print("|---" * (len(modes) + 1) + "|") + for cell in cells: + row = [] + for mode in modes: + got = measure(mode, cell=cell) + row.append("diverged" if got is None + else "%.1e / %.1e" % (got["leak"], got["recovered"])) + print("| %.3f | %s |" % (cell, " | ".join(row)), flush=True) + print() + print("the reaction routes, same solves") + print() + print("| cell size | rotated reaction | multiplier traction |") + print("|---|---|---|") + for cell in cells: + entries = [] + for mode in ("rotated", "constraint"): + got = measure(mode, cell=cell) + entries.append("-" if not got or "reaction" not in got + else "%.1e" % got["reaction"]) + print("| %.3f | %s |" % (cell, " | ".join(entries)), flush=True) + + +def sweep(cells=(0.15, 0.10, 0.075, 0.05), modes=MODES): + """Does the surface stress converge to the exact one, and how fast?""" + exact, residual = exact_amplitude(build("free", cell=0.2)[3]) + print("exact sigma_rr on r = %.2f: %.10f cos(%d.theta), residual %.1e" + % (R_O, exact, N, residual)) + print() + print("relative error in the surface stress amplitude") + print() + print("| cell size | " + " | ".join(modes) + " | rotated (reaction) | multiplier |") + print("|---" * (len(modes) + 3) + "|") + for cell in cells: + row, extra = [], {} + for mode in modes: + got = measure(mode, cell=cell) + row.append("diverged" if got is None else "%.2e" % got["recovered"]) + if got and "reaction" in got: + extra[mode] = "%.2e" % got["reaction"] + print("| %.3f | %s | %s | %s |" + % (cell, " | ".join(row), + extra.get("rotated", "-"), extra.get("constraint", "-")), flush=True) + + +def parameters(cell=CELL): + """The two weak methods against their own parameter, with the leak beside + the stress -- which is where they part company.""" + for mode, values, label in ( + ("penalty", (1e3, 1e4, 1e5, 1e6, 1e8), "penalty (facet normal)"), + ("penalty_node", (1e3, 1e4, 1e5, 1e6), "penalty (node normal)"), + ("nitsche", (1.0, 10.0, 100.0, 1000.0, 1e4), "Nitsche gamma")): + print("\n%s, cell %.3f" % (label, cell)) + print("\n| %s | leak | velocity error | stress error |" + % ("gamma" if mode == "nitsche" else "coefficient")) + print("|---|---|---|---|") + for value in values: + kwargs = {"gamma": value} if mode == "nitsche" else {"penalty": value} + got = measure(mode, cell=cell, **kwargs) + if got is None: + print("| %g | diverged | | |" % value) + continue + print("| %g | %.2e | %.2e | %.2e |" + % (value, got["leak"], got["velocity"], got["recovered"]), flush=True) + + +def locking(cells=(0.15, 0.10, 0.075, 0.05, 0.035), penalty=1.0e6): + """The facet normal, pushed hard, does not converge to free slip. + + Imposing u.n = 0 facet by facet on a curved boundary constrains a corner + node in two directions at once, and the discrete limit is not the smooth + problem. Refine it and it stays wrong -- while the leak, which is the + metric that would normally be trusted, reads 1e-5. + """ + print("direct penalty at %.0e, against the FACET normal" % penalty) + print("\n| cell size | leak | velocity error | stress error |") + print("|---|---|---|---|") + for cell in cells: + got = measure("penalty", cell=cell, penalty=penalty) + if got is None: + print("| %.3f | diverged | | |" % cell) + continue + print("| %.3f | %.2e | %.2e | %.2e |" + % (cell, got["leak"], got["velocity"], got["recovered"]), flush=True) + print("\nthe same coefficient against the measure-weighted NODE normal") + print("\n| cell size | leak | velocity error | stress error |") + print("|---|---|---|---|") + for cell in cells: + got = measure("penalty_node", cell=cell, penalty=penalty) + if got is None: + print("| %.3f | diverged | | |" % cell) + continue + print("| %.3f | %.2e | %.2e | %.2e |" + % (cell, got["leak"], got["velocity"], got["recovered"]), flush=True) + + +def control(cell=CELL): + """Take the boundary condition away. A metric that cannot see that is not + measuring anything.""" + for mode in ("free", "rotated"): + got = measure(mode, cell=cell) + print("%-9s leak %.2e velocity error %.2e stress error %.2e" + % (mode, got["leak"], got["velocity"], got["recovered"]), flush=True) + + +if __name__ == "__main__": + command = sys.argv[1:2] or ["sweep"] + {"sweep": sweep, "both": both, "params": parameters, + "locking": locking, "control": control}[command[0]]() diff --git a/articles/boundary-conditions-on-non-planar-boundaries/examples/timing.py b/articles/boundary-conditions-on-non-planar-boundaries/examples/timing.py new file mode 100644 index 0000000..e068184 --- /dev/null +++ b/articles/boundary-conditions-on-non-planar-boundaries/examples/timing.py @@ -0,0 +1,101 @@ +"""What each treatment costs. + +The note compares four ways of imposing free slip on accuracy. This measures the +other half of the choice: what each one costs to solve, and what its surface +traction costs to recover once solved. + +Two numbers per treatment, because they are paid at different times: + + * the SOLVE. The rotated constraint changes the operator; the multiplier adds a + field and enlarges the saddle point; the weak forms add a boundary term to a + system that is otherwise the plain Stokes one. + * the RECOVERY of the surface traction. The weak forms have to project + `n.sigma.n` out of the solution, which is a second (scalar, symmetric) solve. + The rotated constraint and the multiplier read theirs off the state the solve + already returned, which is arithmetic on the boundary trace and no solve at + all. + +Method: each configuration is built and solved once UNTIMED (JIT compilation, +PETSc setup and the first-touch allocations are not what is being measured), then +timed `repeats` times. Runs are sequential by construction -- concurrent PETSc +solves contend for memory bandwidth and inflate each other by tens of per cent. + + python3 timing.py # the table + python3 timing.py 0.05 # at one cell size + +Run against underworld3 `bugfix/multiplier-traction`. +""" +import sys +import time + +import numpy as np +import sympy + +import underworld3 as uw + +import stress as S + +REPEATS = 3 +# Big enough that a solve is seconds rather than hundredths: at 10k nodes the +# four treatments were separated by less than the run-to-run spread. +CELLS = (0.02, 0.0125) +MODES = ("penalty_node", "nitsche", "constraint", "rotated") + + +def _time(call, repeats=REPEATS): + """Median of `repeats` timings, and the spread, in seconds. + + One UNTIMED call first. The first recovery in a process compiles its + projection: timed cold it read 1.145 s where the same call reads 0.038 s + warm, which is a measurement of the JIT and not of the method. + """ + call() + got = [] + for _ in range(repeats): + start = time.perf_counter() + call() + got.append(time.perf_counter() - start) + return float(np.median(got)), float(np.max(got) - np.min(got)) + + +def solve_cost(mode, cell): + """Seconds to solve, and the nonlinear/linear iteration counts.""" + mesh, stokes, v, exact = S.build(mode, cell=cell) + stokes.solve() # untimed: JIT, setup, first touch + assert S.converged(stokes) + iterations = stokes.snes.getIterationNumber() + median, spread = _time(stokes.solve) + return mesh, stokes, v, exact, median, iterations + + +def recovery_cost(mesh, stokes, mode): + """Seconds to get sigma_nn on the boundary, by the route that treatment has.""" + if mode == "rotated": + return _time(lambda: stokes.boundary_normal_traction("Upper")) + if mode == "constraint": + # The whole traction, h + r(u.n - g). Reading it is an expression build + # plus a boundary-trace evaluation -- no solve. + return _time(lambda: S.multiplier_traction(stokes, stokes.u)) + # The weak forms: project n.sigma.n and read its trace. A scalar solve. + return _time(lambda: S.recovered_traction(mesh, stokes)) + + +def table(cells=CELLS, modes=MODES): + print("seconds, median of %d timed repeats after one untimed warm-up" % REPEATS) + print() + print("| cell size | velocity nodes | " + " | ".join( + "%s: solve / recover" % m for m in modes) + " |") + print("|---" * (len(modes) + 2) + "|") + for cell in cells: + row, nodes = [], None + for mode in modes: + mesh, stokes, v, exact, solve, its = solve_cost(mode, cell) + nodes = len(v.coords) if nodes is None else nodes + recover, _s = recovery_cost(mesh, stokes, mode) + row.append("%.2f / %.3f" % (solve, recover)) + print("| %.3f | %d | %s |" % (cell, nodes, " | ".join(row)), flush=True) + + +if __name__ == "__main__": + cells = (float(sys.argv[1]),) if sys.argv[1:] else CELLS + table(cells=cells) diff --git a/articles/boundary-conditions-on-non-planar-boundaries/examples/topography-data.json b/articles/boundary-conditions-on-non-planar-boundaries/examples/topography-data.json new file mode 100644 index 0000000..f38f753 --- /dev/null +++ b/articles/boundary-conditions-on-non-planar-boundaries/examples/topography-data.json @@ -0,0 +1 @@ +{"contrasts": [100.0, 1000000.0], "truth": {"100.0": [[0.0, 0.01562500000006506, 0.03125000000013012, 0.04687500000019512, 0.06250000000026013, 0.07812500000032518, 0.09375000000039024, 0.10937500000045527, 0.1250000000005203, 0.14062500000058537, 0.1562500000006504, 0.1718750000007154, 0.1875000000007804, 0.20312500000084543, 0.2187500000009105, 0.2343750000009755, 0.2500000000010405, 0.26562500000110556, 0.2812500000011706, 0.2968750000012357, 0.3125000000013007, 0.3281250000013657, 0.3437500000014307, 0.3593750000014958, 0.3750000000015609, 0.3906250000016259, 0.4062500000016909, 0.42187500000175593, 0.437500000001821, 0.453125000001886, 0.468750000001951, 0.48437500000200523, 0.5000000000020595, 0.5156250000019619, 0.5312500000018643, 0.5468750000017559, 0.5625000000016476, 0.5781250000015392, 0.5937500000014309, 0.6093750000013225, 0.625000000001214, 0.6406250000011057, 0.6562500000009973, 0.6718750000008888, 0.6875000000007805, 0.7031250000006721, 0.7187500000005638, 0.7343750000004554, 0.7500000000003471, 0.7656250000002386, 0.7812500000001301, 0.7968750000000216, 0.8124999999999132, 0.8281249999998048, 0.8437499999996964, 0.8593749999995988, 0.8749999999995012, 0.8906249999995555, 0.9062499999996096, 0.9218749999996747, 0.9374999999997398, 0.9531249999998048, 0.9687499999998699, 0.9843749999999349, 1.0], [0.2794438305496565, 0.2792336766663841, 0.27860509329600136, 0.27756371664641966, 0.2761189449799955, 0.27428394554731295, 0.2720756644146153, 0.2695148393052133, 0.26662601560986043, 0.2634375657559502, 0.25998171216052346, 0.25629455402751367, 0.25241609828546413, 0.24839029499817417, 0.2442650776174339, 0.24009240848423946, 0.23592833002271932, 0.23183302210949724, 0.22787086614045374, 0.22411051635688298, 0.22062497903397357, 0.21749170017642513, 0.21479266240896497, 0.2126144917936135, 0.21104857535088614, 0.21019119010879417, 0.21014364455164997, 0.21101243339038472, 0.21290940662749624, 0.2159519539429761, 0.22026320548276043, 0.22597225018855668, 0.03791009878192678, 0.013354106362604068, -0.010480844032193426, -0.03360399506264387, -0.05602180902156315, -0.07773822478757993, -0.09875490409895744, -0.11907146745092187, -0.138685719891038, -0.15759386696074398, -0.17579072100665732, -0.1932698980625954, -0.21002400548230854, -0.22604482048363547, -0.2413234597470689, -0.2558505401954737, -0.2696163310668727, -0.282610897378695, -0.29482423486964227, -0.30624639649422947, -0.3168676105350941, -0.32667839038921354, -0.3356696360761876, -0.34383272750965166, -0.3511596095665777, -0.35764286898384134, -0.36327580310611063, -0.3680524805059488, -0.3719677934919559, -0.3750175025187017, -0.377198272508923, -0.37850770109619103, -0.37894433879410594]], "1000000.0": [[0.0, 0.01562500000006506, 0.03125000000013012, 0.04687500000019512, 0.06250000000026013, 0.07812500000032518, 0.09375000000039024, 0.10937500000045527, 0.1250000000005203, 0.14062500000058537, 0.1562500000006504, 0.1718750000007154, 0.1875000000007804, 0.20312500000084543, 0.2187500000009105, 0.2343750000009755, 0.2500000000010405, 0.26562500000110556, 0.2812500000011706, 0.2968750000012357, 0.3125000000013007, 0.3281250000013657, 0.3437500000014307, 0.3593750000014958, 0.3750000000015609, 0.3906250000016259, 0.4062500000016909, 0.42187500000175593, 0.437500000001821, 0.453125000001886, 0.468750000001951, 0.48437500000200523, 0.5000000000020595, 0.5156250000019619, 0.5312500000018643, 0.5468750000017559, 0.5625000000016476, 0.5781250000015392, 0.5937500000014309, 0.6093750000013225, 0.625000000001214, 0.6406250000011057, 0.6562500000009973, 0.6718750000008888, 0.6875000000007805, 0.7031250000006721, 0.7187500000005638, 0.7343750000004554, 0.7500000000003471, 0.7656250000002386, 0.7812500000001301, 0.7968750000000216, 0.8124999999999132, 0.8281249999998048, 0.8437499999996964, 0.8593749999995988, 0.8749999999995012, 0.8906249999995555, 0.9062499999996096, 0.9218749999996747, 0.9374999999997398, 0.9531249999998048, 0.9687499999998699, 0.9843749999999349, 1.0], [0.27879056265826546, 0.27858570337229305, 0.2779730314553374, 0.2769582662024702, 0.27555094469691077, 0.27376442925837235, 0.2716159179932234, 0.26912645856914497, 0.26632096537230676, 0.2632282402406444, 0.2598809970026598, 0.2563158900873196, 0.2525735475071686, 0.24869860855375314, 0.24473976658191288, 0.24074981729753853, 0.2367857130020395, 0.23290862328611167, 0.22918400270550734, 0.22568166601244988, 0.22247587155821263, 0.21964541352524677, 0.21727372369121403, 0.21544898347243532, 0.21426424704071523, 0.21381757635534415, 0.21421218900143502, 0.2155566197767385, 0.21796489702181682, 0.22155673474309964, 0.22645774163500287, 0.23279964816615384, 0.03680939726302895, 0.012188222315832912, -0.011705092371091049, -0.034880357082689994, -0.05734456400948344, -0.07910214645220835, -0.10015522720934285, -0.12050385645768474, -0.14014623940740678, -0.15907895398616861, -0.17729715878197405, -0.19479479145141543, -0.21156475777865164, -0.22759911155083346, -0.24288922539765032, -0.2574259527261132, -0.2711997808665712, -0.28420097553215773, -0.29641971668134703, -0.3078462258619563, -0.3184708851047058, -0.32828434742526713, -0.3372776389855125, -0.3454422529573666, -0.3527702351261416, -0.3592542612646509, -0.3648877063038704, -0.3696647053225625, -0.3735802063730181, -0.3766300151578111, -0.37881083156900464, -0.38012027809879434, -0.38055692012825537]]}, "data": {"100.0": {"dirichlet": [[0.0, 0.01562500000006506, 0.03125000000013012, 0.04687500000019512, 0.06250000000026013, 0.07812500000032518, 0.09375000000039024, 0.10937500000045527, 0.1250000000005203, 0.14062500000058537, 0.1562500000006504, 0.1718750000007154, 0.1875000000007804, 0.20312500000084543, 0.2187500000009105, 0.2343750000009755, 0.2500000000010405, 0.26562500000110556, 0.2812500000011706, 0.2968750000012357, 0.3125000000013007, 0.3281250000013657, 0.3437500000014307, 0.3593750000014958, 0.3750000000015609, 0.3906250000016259, 0.4062500000016909, 0.42187500000175593, 0.437500000001821, 0.453125000001886, 0.468750000001951, 0.48437500000200523, 0.5000000000020595, 0.5156250000019619, 0.5312500000018643, 0.5468750000017559, 0.5625000000016476, 0.5781250000015392, 0.5937500000014309, 0.6093750000013225, 0.625000000001214, 0.6406250000011057, 0.6562500000009973, 0.6718750000008888, 0.6875000000007805, 0.7031250000006721, 0.7187500000005638, 0.7343750000004554, 0.7500000000003471, 0.7656250000002386, 0.7812500000001301, 0.7968750000000216, 0.8124999999999132, 0.8281249999998048, 0.8437499999996964, 0.8593749999995988, 0.8749999999995012, 0.8906249999995555, 0.9062499999996096, 0.9218749999996747, 0.9374999999997398, 0.9531249999998048, 0.9687499999998699, 0.9843749999999349, 1.0], [0.2782418279108243, 0.27795090847582005, 0.2774011632642354, 0.27628016945796835, 0.2749096468589217, 0.27299898546768503, 0.2708573623719712, 0.2682280887166753, 0.2653954547431387, 0.26214836101975664, 0.2587356150452108, 0.25500264306868486, 0.2511511347800553, 0.24709565503897396, 0.24297848312505035, 0.23879529911563188, 0.2346181571206335, 0.2305334155643952, 0.2265348800525844, 0.22280950764873517, 0.21925945709397013, 0.2161923282455665, 0.21339339993378814, 0.2113188002657265, 0.20957556074635947, 0.20893919527764163, 0.20845420730063483, 0.20991187692890423, 0.20988190560080627, 0.21602237921120912, 0.2104292535820529, 0.23167127253185973, 0.18066180199740106, 0.03642722690571339, -0.034080033793114924, -0.049773364839333034, -0.0525673479605493, -0.08007006850432118, -0.10507379400592183, -0.12408430104956213, -0.14240250492325265, -0.16131478457439166, -0.17984906936951062, -0.19689712203809437, -0.21362162042299337, -0.22932787700518756, -0.24471782303496337, -0.2589115976681528, -0.272785584594719, -0.2854656331625424, -0.29781844254478496, -0.3089326508225187, -0.31971372925302616, -0.32922487059443944, -0.33839662665456205, -0.3462664870648695, -0.35379208667626927, -0.359988876889968, -0.36583653193352944, -0.37033425753757104, -0.3744789565980029, -0.3772569301478814, -0.3796799521989641, -0.3807266465265126, -0.3814158520790764]], "penalty": [[0.0, 0.01562500000006506, 0.03125000000013012, 0.04687500000019512, 0.06250000000026013, 0.07812500000032518, 0.09375000000039024, 0.10937500000045527, 0.1250000000005203, 0.14062500000058537, 0.1562500000006504, 0.1718750000007154, 0.1875000000007804, 0.20312500000084543, 0.2187500000009105, 0.2343750000009755, 0.2500000000010405, 0.26562500000110556, 0.2812500000011706, 0.2968750000012357, 0.3125000000013007, 0.3281250000013657, 0.3437500000014307, 0.3593750000014958, 0.3750000000015609, 0.3906250000016259, 0.4062500000016909, 0.42187500000175593, 0.437500000001821, 0.453125000001886, 0.468750000001951, 0.48437500000200523, 0.5000000000020595, 0.5156250000019619, 0.5312500000018643, 0.5468750000017559, 0.5625000000016476, 0.5781250000015392, 0.5937500000014309, 0.6093750000013225, 0.625000000001214, 0.6406250000011057, 0.6562500000009973, 0.6718750000008888, 0.6875000000007805, 0.7031250000006721, 0.7187500000005638, 0.7343750000004554, 0.7500000000003471, 0.7656250000002386, 0.7812500000001301, 0.7968750000000216, 0.8124999999999132, 0.8281249999998048, 0.8437499999996964, 0.8593749999995988, 0.8749999999995012, 0.8906249999995555, 0.9062499999996096, 0.9218749999996747, 0.9374999999997398, 0.9531249999998048, 0.9687499999998699, 0.9843749999999349, 1.0], [0.2735696836542425, 0.27328042738101943, 0.2727292685916338, 0.2716101010720701, 0.27023843670260234, 0.2683296210145321, 0.26618709362049886, 0.2635594775902302, 0.26072607871062375, 0.25748016017647457, 0.25406661917887613, 0.25033393930902237, 0.24648132367559855, 0.24242474388705515, 0.2383056575485337, 0.2341193962983321, 0.22993887644829866, 0.22584795327274285, 0.22184381450719431, 0.21810761890572875, 0.21454715839667862, 0.21146433163364994, 0.20864298301132658, 0.20654996788564628, 0.20473676750782838, 0.20412467125199002, 0.20333993374855786, 0.20511014673328562, 0.20347329684284457, 0.21185957171318823, 0.19719768689315492, 0.23139278458169485, 0.12485267660005281, 0.012967763993103816, -0.028197538283794402, -0.057895675933199886, -0.06793498241975589, -0.09039858234845806, -0.11041259523263147, -0.12838699051452204, -0.14520457110358692, -0.162132427436048, -0.17868581987356713, -0.1942694784705862, -0.20943360388579807, -0.22382552699372849, -0.23781078611122672, -0.2509035085202354, -0.2635602493318524, -0.2753086905306968, -0.286600732409981, -0.29694266370599603, -0.3068098448390401, -0.3156951497747961, -0.324090271392305, -0.33147298935433295, -0.338353156224385, -0.34419566983705796, -0.34952497153550477, -0.35379724307316285, -0.35754812034559136, -0.36022668222673326, -0.36237908978959077, -0.363450515380249, -0.3639919054598984]], "constraint": [[0.0, 0.01562500000006506, 0.03125000000013012, 0.04687500000019512, 0.06250000000026013, 0.07812500000032518, 0.09375000000039024, 0.10937500000045527, 0.1250000000005203, 0.14062500000058537, 0.1562500000006504, 0.1718750000007154, 0.1875000000007804, 0.20312500000084543, 0.2187500000009105, 0.2343750000009755, 0.2500000000010405, 0.26562500000110556, 0.2812500000011706, 0.2968750000012357, 0.3125000000013007, 0.3281250000013657, 0.3437500000014307, 0.3593750000014958, 0.3750000000015609, 0.3906250000016259, 0.4062500000016909, 0.42187500000175593, 0.437500000001821, 0.453125000001886, 0.468750000001951, 0.48437500000200523, 0.5000000000020595, 0.5156250000019619, 0.5312500000018643, 0.5468750000017559, 0.5625000000016476, 0.5781250000015392, 0.5937500000014309, 0.6093750000013225, 0.625000000001214, 0.6406250000011057, 0.6562500000009973, 0.6718750000008888, 0.6875000000007805, 0.7031250000006721, 0.7187500000005638, 0.7343750000004554, 0.7500000000003471, 0.7656250000002386, 0.7812500000001301, 0.7968750000000216, 0.8124999999999132, 0.8281249999998048, 0.8437499999996964, 0.8593749999995988, 0.8749999999995012, 0.8906249999995555, 0.9062499999996096, 0.9218749999996747, 0.9374999999997398, 0.9531249999998048, 0.9687499999998699, 0.9843749999999349, 1.0], [0.2785268916874456, 0.2782362072813296, 0.2776869780008344, 0.2765668146312993, 0.27519730099262374, 0.2732881720138809, 0.2711481412282027, 0.2685207428606257, 0.26569026483136077, 0.26244567675318464, 0.2590353886996808, 0.25530537671028253, 0.2514568541420485, 0.247404308395994, 0.24329057485016536, 0.23911003822493224, 0.23493615229162712, 0.23085474121392618, 0.2268586626864397, 0.22313658339294942, 0.2195889229988021, 0.21652394204303937, 0.2137274697673687, 0.21165448540580842, 0.20991417750006322, 0.20927536538669214, 0.2087959418075856, 0.21024626081018533, 0.2102304777196122, 0.21634526556566497, 0.21083195418937337, 0.2319343235246382, 0.18140933737821205, 0.03710021137842936, -0.033767913006413165, -0.049437718903158484, -0.05225749017332368, -0.07984288850937456, -0.10493596233475519, -0.12400773437050396, -0.1423879035076937, -0.16136552321687428, -0.17996047779102095, -0.19706771481013513, -0.21384716452295602, -0.22960824730856488, -0.24504901704304766, -0.25929194388038135, -0.2732125512354152, -0.28593517421703807, -0.2983306462766995, -0.3094817777056305, -0.3202999145530307, -0.3298423374087925, -0.339045655091293, -0.34694179522966867, -0.35449298560787923, -0.36071072452809877, -0.3665783491096681, -0.3710910656541535, -0.37524951088462055, -0.3780373337494884, -0.38046797907811386, -0.3815183855556869, -0.38221012110082725]], "constraint+r": [[0.0, 0.01562500000006506, 0.03125000000013012, 0.04687500000019512, 0.06250000000026013, 0.07812500000032518, 0.09375000000039024, 0.10937500000045527, 0.1250000000005203, 0.14062500000058537, 0.1562500000006504, 0.1718750000007154, 0.1875000000007804, 0.20312500000084543, 0.2187500000009105, 0.2343750000009755, 0.2500000000010405, 0.26562500000110556, 0.2812500000011706, 0.2968750000012357, 0.3125000000013007, 0.3281250000013657, 0.3437500000014307, 0.3593750000014958, 0.3750000000015609, 0.3906250000016259, 0.4062500000016909, 0.42187500000175593, 0.437500000001821, 0.453125000001886, 0.468750000001951, 0.48437500000200523, 0.5000000000020595, 0.5156250000019619, 0.5312500000018643, 0.5468750000017559, 0.5625000000016476, 0.5781250000015392, 0.5937500000014309, 0.6093750000013225, 0.625000000001214, 0.6406250000011057, 0.6562500000009973, 0.6718750000008888, 0.6875000000007805, 0.7031250000006721, 0.7187500000005638, 0.7343750000004554, 0.7500000000003471, 0.7656250000002386, 0.7812500000001301, 0.7968750000000216, 0.8124999999999132, 0.8281249999998048, 0.8437499999996964, 0.8593749999995988, 0.8749999999995012, 0.8906249999995555, 0.9062499999996096, 0.9218749999996747, 0.9374999999997398, 0.9531249999998048, 0.9687499999998699, 0.9843749999999349, 1.0], [0.2780250586999775, 0.27773438682315654, 0.2771851800188121, 0.2760650636888428, 0.27469560674976967, 0.2727865576611125, 0.27064661571218995, 0.2680193271236989, 0.26518896671483305, 0.26194451412250713, 0.25853436786333756, 0.25480451157574835, 0.2509561490686256, 0.24690377245854922, 0.242790210027737, 0.23860984786903577, 0.23443613555923398, 0.2303548948167916, 0.2263589825052833, 0.22263705857531693, 0.2190895457148127, 0.21602469274269523, 0.21322833670477423, 0.21115543899248954, 0.20941520331826666, 0.2087764181058338, 0.20829701423008262, 0.2097472730488394, 0.20973149037932884, 0.21584602355777155, 0.21033294181381051, 0.2314344319069696, 0.18016536669378158, 0.036457528857207225, -0.034115311497949524, -0.04971982646962084, -0.052527848749710386, -0.079998307970861, -0.10498682736757695, -0.12397913371335657, -0.14228271879829957, -0.16118126515147083, -0.17969874071626651, -0.19673469765546833, -0.2134442329529046, -0.2291396446364526, -0.2445160777858872, -0.2586996591664178, -0.2725622639532868, -0.2852318760667844, -0.297575700296849, -0.3086803687308042, -0.31945342998369725, -0.3289560927880209, -0.3381210632905599, -0.345984302885642, -0.3535040299590128, -0.35969586164334605, -0.36553903779089797, -0.3700329514100264, -0.374174069745771, -0.3769502766834633, -0.3793707943045827, -0.3804168241192964, -0.38110567741693985]], "rotated": [[0.0, 0.01562500000006506, 0.03125000000013012, 0.04687500000019512, 0.06250000000026013, 0.07812500000032518, 0.09375000000039024, 0.10937500000045527, 0.1250000000005203, 0.14062500000058537, 0.1562500000006504, 0.1718750000007154, 0.1875000000007804, 0.20312500000084543, 0.2187500000009105, 0.2343750000009755, 0.2500000000010405, 0.26562500000110556, 0.2812500000011706, 0.2968750000012357, 0.3125000000013007, 0.3281250000013657, 0.3437500000014307, 0.3593750000014958, 0.3750000000015609, 0.3906250000016259, 0.4062500000016909, 0.42187500000175593, 0.437500000001821, 0.453125000001886, 0.468750000001951, 0.48437500000200523, 0.5000000000020595, 0.5156250000019619, 0.5312500000018643, 0.5468750000017559, 0.5625000000016476, 0.5781250000015392, 0.5937500000014309, 0.6093750000013225, 0.625000000001214, 0.6406250000011057, 0.6562500000009973, 0.6718750000008888, 0.6875000000007805, 0.7031250000006721, 0.7187500000005638, 0.7343750000004554, 0.7500000000003471, 0.7656250000002386, 0.7812500000001301, 0.7968750000000216, 0.8124999999999132, 0.8281249999998048, 0.8437499999996964, 0.8593749999995988, 0.8749999999995012, 0.8906249999995555, 0.9062499999996096, 0.9218749999996747, 0.9374999999997398, 0.9531249999998048, 0.9687499999998699, 0.9843749999999349, 1.0], [0.2780836286127151, 0.27771539632297365, 0.2772428305102198, 0.27604699263763605, 0.2747504930631134, 0.2727703314117768, 0.27069687518109836, 0.2680058830064543, 0.26523270629810397, 0.26193480783388373, 0.25856965089573136, 0.2547995240068448, 0.25098098127662455, 0.2469045168334859, 0.2428025264997572, 0.23861737559553833, 0.23443380033075087, 0.23037029000125492, 0.2263398706608663, 0.2226613613295593, 0.21905211615813608, 0.21605840296978365, 0.2131750807386179, 0.21119556380202675, 0.20937285640589257, 0.20879921200153523, 0.20843453486593808, 0.2096075178676945, 0.21104770070592893, 0.21469010147701914, 0.21866498925289288, 0.22427876033188035, 0.18039142951778595, 0.043777232514659314, -0.04270028156733962, -0.048433405359016087, -0.05405013382132685, -0.07974361695642906, -0.10529693172802608, -0.12390339680101578, -0.14238283266565382, -0.16113615447979718, -0.17976235748776243, -0.19669488095931784, -0.21350152330931566, -0.2291004440016308, -0.24457269294554113, -0.25866014444221747, -0.27261945765994294, -0.2851917842944213, -0.2976338672581672, -0.3086396132955738, -0.31951271238821494, -0.32891466057816465, -0.3381814738183109, -0.34594223273495883, -0.3535654865484508, -0.35965323304082075, -0.36560138395742436, -0.36998987602458144, -0.37423709186296483, -0.3769068901616813, -0.37943423816495914, -0.38037327805168714, -0.38116926455346495]]}, "1000000.0": {"dirichlet": [[0.0, 0.01562500000006506, 0.03125000000013012, 0.04687500000019512, 0.06250000000026013, 0.07812500000032518, 0.09375000000039024, 0.10937500000045527, 0.1250000000005203, 0.14062500000058537, 0.1562500000006504, 0.1718750000007154, 0.1875000000007804, 0.20312500000084543, 0.2187500000009105, 0.2343750000009755, 0.2500000000010405, 0.26562500000110556, 0.2812500000011706, 0.2968750000012357, 0.3125000000013007, 0.3281250000013657, 0.3437500000014307, 0.3593750000014958, 0.3750000000015609, 0.3906250000016259, 0.4062500000016909, 0.42187500000175593, 0.437500000001821, 0.453125000001886, 0.468750000001951, 0.48437500000200523, 0.5000000000020595, 0.5156250000019619, 0.5312500000018643, 0.5468750000017559, 0.5625000000016476, 0.5781250000015392, 0.5937500000014309, 0.6093750000013225, 0.625000000001214, 0.6406250000011057, 0.6562500000009973, 0.6718750000008888, 0.6875000000007805, 0.7031250000006721, 0.7187500000005638, 0.7343750000004554, 0.7500000000003471, 0.7656250000002386, 0.7812500000001301, 0.7968750000000216, 0.8124999999999132, 0.8281249999998048, 0.8437499999996964, 0.8593749999995988, 0.8749999999995012, 0.8906249999995555, 0.9062499999996096, 0.9218749999996747, 0.9374999999997398, 0.9531249999998048, 0.9687499999998699, 0.9843749999999349, 1.0], [0.27752274369103486, 0.27724027321740596, 0.27670345943583224, 0.2756124375114777, 0.2742765303892372, 0.27241795635766675, 0.27033338912412286, 0.26777936416287207, 0.265027448559996, 0.2618802838820674, 0.2585736139450435, 0.2549672768107007, 0.2512494206790332, 0.24734978614149308, 0.24339659542086076, 0.23940161875775093, 0.235422103173992, 0.23156153892293302, 0.22779814123708836, 0.22433768100133936, 0.22106524838005803, 0.21830751667913043, 0.21583244039896896, 0.2141232426341522, 0.21275992257475634, 0.2125349352506103, 0.21246462671910016, 0.21447426697403696, 0.21492221328688862, 0.2215784218665878, 0.21596015497183804, 0.2391933134896972, 0.18689817251492444, 0.03654286149267027, -0.03677064670728274, -0.052000462632792066, -0.05376432467841929, -0.08161405497668446, -0.10686726794956597, -0.12583712299055602, -0.14409872060269907, -0.16303945310787604, -0.18160045058577629, -0.19864824208265927, -0.21537333894205118, -0.23108210021829773, -0.24647600643344675, -0.26066837976758317, -0.2745426518951873, -0.28722023127896706, -0.29957239363151517, -0.31068308804462297, -0.32146262111321877, -0.3309698476907814, -0.3401397662558658, -0.34800567050601244, -0.3555294883396407, -0.36172261850795273, -0.36756884655580196, -0.3720634566732739, -0.3762073113521223, -0.37898288075599185, -0.3814058016747456, -0.38245091115743074, -0.3831408425465471]], "penalty": [[0.0, 0.01562500000006506, 0.03125000000013012, 0.04687500000019512, 0.06250000000026013, 0.07812500000032518, 0.09375000000039024, 0.10937500000045527, 0.1250000000005203, 0.14062500000058537, 0.1562500000006504, 0.1718750000007154, 0.1875000000007804, 0.20312500000084543, 0.2187500000009105, 0.2343750000009755, 0.2500000000010405, 0.26562500000110556, 0.2812500000011706, 0.2968750000012357, 0.3125000000013007, 0.3281250000013657, 0.3437500000014307, 0.3593750000014958, 0.3750000000015609, 0.3906250000016259, 0.4062500000016909, 0.42187500000175593, 0.437500000001821, 0.453125000001886, 0.468750000001951, 0.48437500000200523, 0.5000000000020595, 0.5156250000019619, 0.5312500000018643, 0.5468750000017559, 0.5625000000016476, 0.5781250000015392, 0.5937500000014309, 0.6093750000013225, 0.625000000001214, 0.6406250000011057, 0.6562500000009973, 0.6718750000008888, 0.6875000000007805, 0.7031250000006721, 0.7187500000005638, 0.7343750000004554, 0.7500000000003471, 0.7656250000002386, 0.7812500000001301, 0.7968750000000216, 0.8124999999999132, 0.8281249999998048, 0.8437499999996964, 0.8593749999995988, 0.8749999999995012, 0.8906249999995555, 0.9062499999996096, 0.9218749999996747, 0.9374999999997398, 0.9531249999998048, 0.9687499999998699, 0.9843749999999349, 1.0], [0.03707296456476497, 0.03679264992088132, 0.03625492691436553, 0.035167104120198654, 0.03383137592427892, 0.03197696986926467, 0.029893715670391688, 0.027344755789535498, 0.024595470116414928, 0.02145388323830698, 0.018151126507079913, 0.014551109027297985, 0.01083810019419012, 0.00694505902605946, 0.0029980672434704483, -0.0009904752823614786, -0.004963146554707754, -0.008816864306153391, -0.012572798927258641, -0.016027103552958628, -0.01929010347907712, -0.022045297383832287, -0.024502492427756734, -0.026222286804872066, -0.02752422233618396, -0.027833744739178767, -0.027590598855032036, -0.026075714664543052, -0.0238572572987161, -0.020059960627347738, -0.015392598353528325, -0.008826721071711003, -0.001281012101620822, -0.001518701629125231, -0.0018336632746079108, -0.0016736092512719095, -0.0018211021186866033, -0.0016417789721390808, -0.0017604682190824085, -0.0016373258901021047, -0.0017029809533199913, -0.0016282145389745072, -0.0016678007626996152, -0.00163132195079971, -0.0016489543400477458, -0.0016411257871703224, -0.0016422312909281135, -0.0016560291426537036, -0.0016440812717554512, -0.00167335361689651, -0.0016502567386534083, -0.0016918845368805563, -0.001659172570369999, -0.0017097703840736102, -0.0016694110228489861, -0.0017258527950372476, -0.001679062966815871, -0.0017396878310142051, -0.0016871265235052447, -0.0017506307847022313, -0.001693375943411998, -0.0017580219737199036, -0.0016973496430511515, -0.0017620361049403433, -0.0016984965303754778]], "constraint": [[0.0, 0.01562500000006506, 0.03125000000013012, 0.04687500000019512, 0.06250000000026013, 0.07812500000032518, 0.09375000000039024, 0.10937500000045527, 0.1250000000005203, 0.14062500000058537, 0.1562500000006504, 0.1718750000007154, 0.1875000000007804, 0.20312500000084543, 0.2187500000009105, 0.2343750000009755, 0.2500000000010405, 0.26562500000110556, 0.2812500000011706, 0.2968750000012357, 0.3125000000013007, 0.3281250000013657, 0.3437500000014307, 0.3593750000014958, 0.3750000000015609, 0.3906250000016259, 0.4062500000016909, 0.42187500000175593, 0.437500000001821, 0.453125000001886, 0.468750000001951, 0.48437500000200523, 0.5000000000020595, 0.5156250000019619, 0.5312500000018643, 0.5468750000017559, 0.5625000000016476, 0.5781250000015392, 0.5937500000014309, 0.6093750000013225, 0.625000000001214, 0.6406250000011057, 0.6562500000009973, 0.6718750000008888, 0.6875000000007805, 0.7031250000006721, 0.7187500000005638, 0.7343750000004554, 0.7500000000003471, 0.7656250000002386, 0.7812500000001301, 0.7968750000000216, 0.8124999999999132, 0.8281249999998048, 0.8437499999996964, 0.8593749999995988, 0.8749999999995012, 0.8906249999995555, 0.9062499999996096, 0.9218749999996747, 0.9374999999997398, 0.9531249999998048, 0.9687499999998699, 0.9843749999999349, 1.0], [0.023484259415375942, 0.0232020202711758, 0.02266571018525724, 0.02157549803804424, 0.02024057302121921, 0.018383491150676036, 0.016300474461657145, 0.013748266085288709, 0.010998446344867498, 0.007853709897319767, 0.004549423301431272, 0.0009459401198293258, -0.0027690294801543326, -0.0066658401469344925, -0.010615725615785464, -0.014608205604802131, -0.018584558628493973, -0.022442049465185125, -0.026202827658879733, -0.029660757409433962, -0.03292898935429569, -0.035688075444630256, -0.03814912599179709, -0.03987610828192619, -0.04116771160847842, -0.041507184303919996, -0.04116958345804227, -0.03981866615117834, -0.03701046147997857, -0.03417396172279246, -0.02603671437742066, -0.025055396908712883, 0.002842712802135537, 0.004564911787143319, 0.006884826327662872, 0.006940148907527011, 0.007078792429550493, 0.007702410701266821, 0.008335927445931493, 0.008792982524708611, 0.009244054863143598, 0.009707128272565209, 0.010162760464173751, 0.010581189525329281, 0.010991618637396445, 0.011377344184664313, 0.011754999875756839, 0.012103601444532975, 0.012443992894976816, 0.012755394406333755, 0.013058447303511023, 0.01333140423948434, 0.01359583675196847, 0.013829442671310137, 0.01405434683024299, 0.014247684523135613, 0.014432149934686913, 0.014584436157581693, 0.014727709183049822, 0.014838293503167059, 0.01493976415433261, 0.015008169622097958, 0.015067384526976983, 0.01509330391127834, 0.015109989993076806]], "constraint+r": [[0.0, 0.01562500000006506, 0.03125000000013012, 0.04687500000019512, 0.06250000000026013, 0.07812500000032518, 0.09375000000039024, 0.10937500000045527, 0.1250000000005203, 0.14062500000058537, 0.1562500000006504, 0.1718750000007154, 0.1875000000007804, 0.20312500000084543, 0.2187500000009105, 0.2343750000009755, 0.2500000000010405, 0.26562500000110556, 0.2812500000011706, 0.2968750000012357, 0.3125000000013007, 0.3281250000013657, 0.3437500000014307, 0.3593750000014958, 0.3750000000015609, 0.3906250000016259, 0.4062500000016909, 0.42187500000175593, 0.437500000001821, 0.453125000001886, 0.468750000001951, 0.48437500000200523, 0.5000000000020595, 0.5156250000019619, 0.5312500000018643, 0.5468750000017559, 0.5625000000016476, 0.5781250000015392, 0.5937500000014309, 0.6093750000013225, 0.625000000001214, 0.6406250000011057, 0.6562500000009973, 0.6718750000008888, 0.6875000000007805, 0.7031250000006721, 0.7187500000005638, 0.7343750000004554, 0.7500000000003471, 0.7656250000002386, 0.7812500000001301, 0.7968750000000216, 0.8124999999999132, 0.8281249999998048, 0.8437499999996964, 0.8593749999995988, 0.8749999999995012, 0.8906249999995555, 0.9062499999996096, 0.9218749999996747, 0.9374999999997398, 0.9531249999998048, 0.9687499999998699, 0.9843749999999349, 1.0], [0.27827295232270866, 0.27799072510310396, 0.27745443720660745, 0.27636427064045477, 0.2750294011185695, 0.2731723967515867, 0.2710894667243674, 0.2685373648332267, 0.26578765955658706, 0.26264305426825607, 0.2593389052436307, 0.2557355723214629, 0.2520207574277194, 0.24812410922470843, 0.24417438823560103, 0.24018207473569753, 0.23620588723992864, 0.23234855731648824, 0.22858793567393898, 0.22513015012911847, 0.22186205430977357, 0.21910308321731584, 0.21664213512909292, 0.21491522497103693, 0.2136236750906849, 0.21328421703098704, 0.21362180320186464, 0.21497266484680805, 0.21778075195790858, 0.22061713396262916, 0.2287540420553322, 0.22973531876440098, 0.11736021141805625, 0.04732052773252468, -0.04702147239602905, -0.049272870985694495, -0.05490970755874752, -0.08027137695252728, -0.10603327572315013, -0.12462136379886418, -0.14296382090562548, -0.16179686958535233, -0.18032452820810507, -0.19734223078574897, -0.21403167270841106, -0.22971917816819126, -0.24507620003831437, -0.2592537139202632, -0.2730954345838461, -0.28576010971311017, -0.29808349511917365, -0.3091844056706605, -0.319937573371295, -0.32943784544782484, -0.33858382981943347, -0.3464461797440681, -0.3539480236370066, -0.36014080522941005, -0.3659676175949522, -0.3704646839739383, -0.3745913860235228, -0.3773730433634762, -0.3797812751972055, -0.3808350600318977, -0.38151385950633276]], "rotated": [[0.0, 0.01562500000006506, 0.03125000000013012, 0.04687500000019512, 0.06250000000026013, 0.07812500000032518, 0.09375000000039024, 0.10937500000045527, 0.1250000000005203, 0.14062500000058537, 0.1562500000006504, 0.1718750000007154, 0.1875000000007804, 0.20312500000084543, 0.2187500000009105, 0.2343750000009755, 0.2500000000010405, 0.26562500000110556, 0.2812500000011706, 0.2968750000012357, 0.3125000000013007, 0.3281250000013657, 0.3437500000014307, 0.3593750000014958, 0.3750000000015609, 0.3906250000016259, 0.4062500000016909, 0.42187500000175593, 0.437500000001821, 0.453125000001886, 0.468750000001951, 0.48437500000200523, 0.5000000000020595, 0.5156250000019619, 0.5312500000018643, 0.5468750000017559, 0.5625000000016476, 0.5781250000015392, 0.5937500000014309, 0.6093750000013225, 0.625000000001214, 0.6406250000011057, 0.6562500000009973, 0.6718750000008888, 0.6875000000007805, 0.7031250000006721, 0.7187500000005638, 0.7343750000004554, 0.7500000000003471, 0.7656250000002386, 0.7812500000001301, 0.7968750000000216, 0.8124999999999132, 0.8281249999998048, 0.8437499999996964, 0.8593749999995988, 0.8749999999995012, 0.8906249999995555, 0.9062499999996096, 0.9218749999996747, 0.9374999999997398, 0.9531249999998048, 0.9687499999998699, 0.9843749999999349, 1.0], [0.2773645142220426, 0.2770060688807109, 0.2765450633890458, 0.2753805502872182, 0.27411721394250915, 0.27219055431830586, 0.270172569837212, 0.2675583549103214, 0.264864124809081, 0.26166785067861936, 0.2584067587750641, 0.25476517535677, 0.25107796436136676, 0.2471595536987249, 0.24321883493795293, 0.23922443966955664, 0.23523535645694285, 0.23139898110803986, 0.22760004918816168, 0.22418987272589588, 0.2208540370406222, 0.21817363982107119, 0.21560955151308286, 0.21399951597823821, 0.21255319001604114, 0.21239267713569004, 0.2124491323385774, 0.21415914374546688, 0.21614573306724275, 0.22018794453786592, 0.22457317893160572, 0.2314676491421537, 0.1866197430100396, 0.04422423108761631, -0.0457764628855414, -0.050601873930320895, -0.055312151321664345, -0.08127631924355917, -0.10710005268126711, -0.12565292652483745, -0.14407914993973828, -0.16285900112620205, -0.18151211254900068, -0.19844439117336296, -0.21525131124255012, -0.23085311952189194, -0.24632886003262447, -0.26041539688483256, -0.27437447721509206, -0.2869448623421712, -0.29938574686970465, -0.3103885533909812, -0.3212595067422845, -0.33065815280881056, -0.339922498263013, -0.3476799486158023, -0.35530076019385726, -0.36138551651158, -0.3673315679605391, -0.37171762380416096, -0.37596331172230896, -0.3786313961517514, -0.38115794999324015, -0.3820960984472562, -0.38289211882890845]]}}} \ No newline at end of file diff --git a/articles/boundary-conditions-on-non-planar-boundaries/figures/banner.png b/articles/boundary-conditions-on-non-planar-boundaries/figures/banner.png new file mode 100644 index 0000000..0147e66 Binary files /dev/null and b/articles/boundary-conditions-on-non-planar-boundaries/figures/banner.png differ diff --git a/articles/boundary-conditions-on-non-planar-boundaries/figures/locking.png b/articles/boundary-conditions-on-non-planar-boundaries/figures/locking.png new file mode 100644 index 0000000..e31caca Binary files /dev/null and b/articles/boundary-conditions-on-non-planar-boundaries/figures/locking.png differ diff --git a/articles/boundary-conditions-on-non-planar-boundaries/figures/rotated-basis.png b/articles/boundary-conditions-on-non-planar-boundaries/figures/rotated-basis.png new file mode 100644 index 0000000..9d9ca00 Binary files /dev/null and b/articles/boundary-conditions-on-non-planar-boundaries/figures/rotated-basis.png differ diff --git a/articles/boundary-conditions-on-non-planar-boundaries/figures/rotated-basis.svg b/articles/boundary-conditions-on-non-planar-boundaries/figures/rotated-basis.svg new file mode 100644 index 0000000..e72529d --- /dev/null +++ b/articles/boundary-conditions-on-non-planar-boundaries/figures/rotated-basis.svg @@ -0,0 +1,2118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/articles/boundary-conditions-on-non-planar-boundaries/figures/topography.png b/articles/boundary-conditions-on-non-planar-boundaries/figures/topography.png new file mode 100644 index 0000000..062cba1 Binary files /dev/null and b/articles/boundary-conditions-on-non-planar-boundaries/figures/topography.png differ diff --git a/articles/boundary-conditions-on-non-planar-boundaries/metadata.yml b/articles/boundary-conditions-on-non-planar-boundaries/metadata.yml new file mode 100644 index 0000000..0df5ea8 --- /dev/null +++ b/articles/boundary-conditions-on-non-planar-boundaries/metadata.yml @@ -0,0 +1,38 @@ +# Validated in CI against schemas/article-metadata.schema.json. +# `pixi run validate` checks this and the cross-file invariants a schema cannot +# express -- that the article file is named .md, that canonical_path +# matches the slug, and that no legacy DOI is ever paired with a new registrant. +id: UWTN 2026-016 +slug: boundary-conditions-on-non-planar-boundaries +title: Boundary conditions on non-planar boundaries +article_type: technical-note +status: review +authors: + - name: Louis Moresi + orcid: 0000-0003-3685-174X + affiliation: Australian National University +publication_date: null +version: 1.0.0 +# The deposit writes archive_doi and repository_record_id when the note is +# published; leave them out until then. `doi` and `doi_registrant` were here +# once and are not fields the schema knows -- every note made from this +# template failed `pixi run validate` on all three of them. +license: CC-BY-4.0 +canonical_path: /boundary-conditions-on-non-planar-boundaries/ +legacy_paths: [] +# Facets, from vocabulary.yml. Both keys must be present even when empty: a +# note with no subject is normal -- many are purely about method. +subjects: + - surface-processes +methods: + - solvers + - finite-elements + - meshing +ghost_tags: + - Underworld Code +figures: 3 +# Rendered from the model rather than a stock photograph, so there is nobody to +# credit. `figures` counts the figures in the body; the banner is not one. +banner: figures/banner.png +banner_credit: null +source: native diff --git a/articles/boundary-conditions-on-non-planar-boundaries/references.bib b/articles/boundary-conditions-on-non-planar-boundaries/references.bib new file mode 100644 index 0000000..d8c2b29 --- /dev/null +++ b/articles/boundary-conditions-on-non-planar-boundaries/references.bib @@ -0,0 +1,39 @@ +% Pinned here rather than fetched at build time, following the convention in the +% MMPDE note: a build that depends on doi.org answering can publish a note with a +% broken citation, and a deposited PDF cannot be repaired afterwards. +% +% Every citation in this note is pinned, not only the awkward one. The preview +% build failed on CI with "Citation data from doi.org was not available or +% malformed" for two of them while resolving fine locally: a build that depends +% on doi.org answering will eventually publish a note with a broken citation, and +% a deposited PDF cannot be repaired afterwards. +% +% The Zhong entry has a second reason. The DOI contains parentheses, +% 10.1016/0031-9201(93)90078-N, and MyST's inline DOI citation form stops at the +% first "(" -- the build reported: Could not find DOI "10.1016/0031-9201". A +% bibtex key sidesteps that. +% +% Do not write an at-sign in these comments. The bibtex reader scans for one +% wherever it appears and then expects an entry: an at-sign inside a comment +% here failed the whole file with "expected lbrace, got label". + +@article{Zhong_1993, + title = {Accurate determination of surface normal stress in viscous flow from a consistent boundary flux method}, + volume = {78}, + ISSN = {0031-9201}, + url = {http://dx.doi.org/10.1016/0031-9201(93)90078-N}, + DOI = {10.1016/0031-9201(93)90078-n}, + number = {1-2}, + journal = {Physics of the Earth and Planetary Interiors}, + publisher = {Elsevier BV}, + author = {Zhong, Shijie and Gurnis, Michael and Hulbert, Gregory}, + year = {1993}, + month = jun, + pages = {1--8} +} + +@article{Nitsche_1971, title={Über ein Variationsprinzip zur Lösung von Dirichlet-Problemen bei Verwendung von Teilräumen, die keinen Randbedingungen unterworfen sind}, volume={36}, ISSN={1865-8784}, url={http://dx.doi.org/10.1007/BF02995904}, DOI={10.1007/bf02995904}, number={1}, journal={Abhandlungen aus dem Mathematischen Seminar der Universität Hamburg}, publisher={Springer Science and Business Media LLC}, author={Nitsche, J.}, year={1971}, month=July, pages={9–15} } + +@article{Engelman_1982, title={The implementation of normal and/or tangential boundary conditions in finite element codes for incompressible fluid flow}, volume={2}, ISSN={1097-0363}, url={http://dx.doi.org/10.1002/fld.1650020302}, DOI={10.1002/fld.1650020302}, number={3}, journal={International Journal for Numerical Methods in Fluids}, publisher={Wiley}, author={Engelman, M. S. and Sani, R. L. and Gresho, P. M.}, year={1982}, month=July, pages={225–238} } + +@article{Kramer_2021, title={Analytical solutions for mantle flow in cylindrical and spherical shells}, volume={14}, ISSN={1991-9603}, url={http://dx.doi.org/10.5194/gmd-14-1899-2021}, DOI={10.5194/gmd-14-1899-2021}, number={4}, journal={Geoscientific Model Development}, publisher={Copernicus GmbH}, author={Kramer, Stephan C. and Davies, D. Rhodri and Wilson, Cian R.}, year={2021}, month=Apr, pages={1899–1919} } diff --git a/classification.yml b/classification.yml index 65201be..4925dd5 100644 --- a/classification.yml +++ b/classification.yml @@ -322,3 +322,7 @@ setting-up-full-multigrid: article_type: technical-note subjects: [mantle-convection] methods: [solvers, meshing, parallel-hpc] +boundary-conditions-on-non-planar-boundaries: + article_type: technical-note + subjects: [surface-processes] + methods: [solvers, finite-elements, meshing] diff --git a/scripts/new_article.py b/scripts/new_article.py index a010c81..c60324f 100644 --- a/scripts/new_article.py +++ b/scripts/new_article.py @@ -17,6 +17,7 @@ import datetime import pathlib import re +import subprocess import sys ROOT = pathlib.Path(__file__).resolve().parent.parent @@ -44,6 +45,37 @@ def load_authors(): return registry +def ids_on_branches(year): + """Article numbers claimed on a branch, merged or not. + + The rest of the allocator reads the working tree, which cannot see a number + claimed by a note still in review on its own branch. Two notes drafted in + parallel were therefore both offered the same number, and UWTN 2026-012 was + claimed twice before anyone noticed. + + Every local and remote-tracking ref is searched, so this covers open pull + requests as far as they have been fetched. A note on a branch that has never + been pushed to a remote this checkout tracks is still invisible, which is why + `pixi run validate` checks for duplicates as well. + """ + def git(*args): + return subprocess.run(("git",) + args, cwd=str(ROOT), + capture_output=True, text=True) + + refs = git("for-each-ref", "--format=%(refname)", "refs/heads", "refs/remotes") + if refs.returncode != 0: + return set() # no git, or not a checkout: the tree is all we have + + used = set() + for ref in refs.stdout.split(): + found = git("grep", "-h", "-E", r"^id: +UWTN +[0-9]{4}-[0-9]{3}", + ref, "--", "articles/*/metadata.yml") + for match in re.finditer(r"UWTN\s+(\d{4})-(\d{3})", found.stdout): + if match.group(1) == str(year): + used.add(int(match.group(2))) + return used + + def next_article_id(year): """Allocate an ID that no existing article uses. @@ -52,7 +84,7 @@ def next_article_id(year): number already present in that year keeps a new note clear of anything the backfill will produce, and an ID that has been published never moves. """ - used = set() + used = ids_on_branches(year) for meta in ARTICLES.glob("*/metadata.yml"): match = re.search(r"^id:\s*(UWTN\s+(\d{4})-(\d{3}))\s*$", meta.read_text(encoding="utf-8"), re.M)