diff --git a/grudge/symbolic/primitives.py b/grudge/symbolic/primitives.py index d2f1a01f1c5415e7c2877625156bc0087ab104a6..0671046217b942c81752fb89a95a75046fed3c0c 100644 --- a/grudge/symbolic/primitives.py +++ b/grudge/symbolic/primitives.py @@ -538,7 +538,9 @@ def parametrization_derivative(ambient_dim, dim=None, dd=None): dim = ambient_dim if dim == 0: - return MultiVector(np.array([_SignedFaceOnes(dd)])) + from pymbolic.geometric_algebra import get_euclidean_space + return MultiVector(_SignedFaceOnes(dd), + space=get_euclidean_space(ambient_dim)) from pytools import product return product( @@ -626,14 +628,8 @@ def area_element(ambient_dim, dim=None, dd=None): "area_element", cse_scope.DISCRETIZATION) -def mv_normal(dd, ambient_dim, dim=None): - """Exterior unit normal as a :class:`~pymbolic.geometric_algebra.MultiVector`.""" - +def surface_normal(ambient_dim, dim=None, dd=None): dd = as_dofdesc(dd) - - if not dd.is_trace(): - raise ValueError("may only request normals on boundaries") - if dim is None: dim = ambient_dim - 1 @@ -644,23 +640,46 @@ def mv_normal(dd, ambient_dim, dim=None): / area_element(ambient_dim, dim, dd=dd) # Dorst Section 3.7.2 - mv = pder << pder.I.inv() + return cse(pder << pder.I.inv(), + "surface_normal", + cse_scope.DISCRETIZATION) - if dim == 0: - # NOTE: when the mesh is 0D, we do not have a clear notion of - # `exterior normal`, so we just take the tangent of the 1D element, - # interpolate it to the element faces and make it signed - tangent = parametrization_derivative( - ambient_dim, dim=1, dd=DD_VOLUME) - tangent = tangent / sqrt(tangent.norm_squared()) - from grudge.symbolic.operators import interp - interp = interp(DD_VOLUME, dd) - mv = MultiVector(np.array([ - mv.as_scalar() * interp(t) for t in tangent.as_vector() - ])) +def mv_normal(dd, ambient_dim, dim=None): + """Exterior unit normal as a :class:`~pymbolic.geometric_algebra.MultiVector`.""" + dd = as_dofdesc(dd) + if not dd.is_trace(): + raise ValueError("may only request normals on boundaries") + + if dim is None: + dim = ambient_dim - 1 + + if dim == ambient_dim - 1: + return surface_normal(ambient_dim, dim=dim, dd=dd) - return cse(mv, "normal", cse_scope.DISCRETIZATION) + # NOTE: In the case of (d - 2)-dimensional curves, we don't really have + # enough information on the face to decide what an "exterior face normal" + # is (e.g the "normal" to a 1D curve in 3D space is actually a + # "normal plane") + # + # The trick done here is that we take the surface normal, move it to the + # face and then take a cross product with the face normal to get the + # correct exterior face normal vector. + assert dim == ambient_dim - 2 + + from grudge.symbolic.operators import interp + volm_normal = ( + surface_normal(ambient_dim, dim=dim + 1, dd=DD_VOLUME) + .map(interp(DD_VOLUME, dd))) + pder = pseudoscalar(ambient_dim, dim, dd=dd) + + mv = cse(-(volm_normal ^ pder) << volm_normal.I.inv(), + "face_normal", + cse_scope.DISCRETIZATION) + + return cse(mv / sqrt(mv.norm_squared()), + "unit_face_normal", + cse_scope.DISCRETIZATION) def normal(dd, ambient_dim, dim=None, quadrature_tag=None): diff --git a/test/test_grudge.py b/test/test_grudge.py index b41e003cd1e7908001de8fb8cfdb6a4b912f395f..3bf5ddf4f7fcccb083098e75cd24bf7e346afbd2 100644 --- a/test/test_grudge.py +++ b/test/test_grudge.py @@ -44,6 +44,8 @@ logger = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO) +# {{{ inverse metric + @pytest.mark.parametrize("dim", [2, 3]) def test_inverse_metric(ctx_factory, dim): cl_ctx = ctx_factory() @@ -88,6 +90,10 @@ def test_inverse_metric(ctx_factory, dim): logger.info("error[%d, %d]: %.5e", i, j, err) assert err < 1.0e-12, (i, j, err) +# }}} + + +# {{{ mass operator trig integration @pytest.mark.parametrize("ambient_dim", [1, 2, 3]) @pytest.mark.parametrize("quad_tag", [sym.QTAG_NONE, "OVSMP"]) @@ -154,6 +160,10 @@ def test_mass_mat_trig(ctx_factory, ambient_dim, quad_tag): err_3 = abs(num_integral_3 - true_integral) assert err_3 < 5.0e-10, err_3 +# }}} + + +# {{{ mass operator surface area def _ellipse_surface_area(radius, aspect_ratio): # https://docs.scipy.org/doc/scipy/reference/generated/scipy.special.ellipe.html @@ -250,6 +260,10 @@ def test_mass_surface_area(ctx_factory, name): assert eoc.max_error() < 1.0e-14 \ or eoc.order_estimate() >= (builder.order - 1.0) +# }}} + + +# {{{ surface mass inverse @pytest.mark.parametrize("name", ["2-1-ellipse", "spheroid"]) def test_surface_mass_operator_inverse(ctx_factory, name): @@ -314,6 +328,119 @@ def test_surface_mass_operator_inverse(ctx_factory, name): assert eoc.max_error() < 5.0e-09 \ or eoc.order_estimate() >= (builder.order - 1.0) +# }}} + + +# {{{ surface face normal orthogonality + +def _avg_face_normal(dd, ambient_dim, dim=None): + dd = sym.as_dofdesc(dd) + assert dd.is_trace() + + if dim is None: + dim = ambient_dim - 1 + + face_normal_i = sym.normal(dd, ambient_dim, dim=dim) + face_normal_e = sym.OppositeInteriorFaceSwap()(face_normal_i) + face_normal = (face_normal_i - face_normal_e) / 2.0 + + return sym.cse( + face_normal / sym.sqrt(face_normal.dot(face_normal)), + "avg_face_normal", + sym.cse_scope.DISCRETIZATION) + + +@pytest.mark.parametrize("mesh_name", ["2-1-ellipse", "spheroid"]) +def test_face_normal_surface(ctx_factory, mesh_name): + """Check that face normals are orthogonal to the surface normal""" + + cl_ctx = ctx_factory() + queue = cl.CommandQueue(cl_ctx) + + # {{{ geometry + + if mesh_name == "2-1-ellipse": + from mesh_data import EllipseMeshBuilder + builder = EllipseMeshBuilder(radius=3.1, aspect_ratio=2.0) + elif mesh_name == "spheroid": + from mesh_data import SpheroidMeshBuilder + builder = SpheroidMeshBuilder() + else: + raise ValueError("unknown mesh name: %s" % mesh_name) + + mesh = builder.get_mesh(builder.resolutions[1], builder.mesh_order) + discr = DGDiscretizationWithBoundaries(cl_ctx, mesh, order=builder.order) + + # }}} + + # {{{ symbolic + + dv = sym.DD_VOLUME + df = sym.as_dofdesc(sym.FACE_RESTR_INTERIOR) + + ambient_dim = mesh.ambient_dim + surf_dim = mesh.dim + face_dim = surf_dim - 1 + + sym_surf_normal = sym.interp(dv, df)( + sym.surface_normal(ambient_dim, dim=surf_dim, dd=dv).as_vector() + ) + sym_surf_normal = sym_surf_normal / sym.sqrt(sum(sym_surf_normal**2)) + + sym_face_normal_i = sym.normal(df, ambient_dim, dim=face_dim) + sym_face_normal_e = sym.OppositeInteriorFaceSwap(df)(sym_face_normal_i) + + sym_face_normal_avg = _avg_face_normal(df, ambient_dim, dim=face_dim) + sym_face_normal_op = sym.OppositeInteriorFaceSwap(df)(sym_face_normal_avg) + + if mesh.ambient_dim == 3: + # NOTE: there's only one face tangent in 3d + sym_face_tangent = (sym.pseudoscalar(ambient_dim, face_dim, dd=df) + / sym.area_element(ambient_dim, face_dim, dd=df)).as_vector() + + # }}} + + # {{{ checks + + rtol = 1.0e-14 + + surf_normal = bind(discr, sym_surf_normal)(queue) + + face_normal_i = bind(discr, sym_face_normal_i)(queue) + face_normal_e = bind(discr, sym_face_normal_e)(queue) + + face_normal_avg = bind(discr, sym_face_normal_avg)(queue) + face_normal_op = bind(discr, sym_face_normal_op)(queue) + + # check interpolated surface normal is orthogonal to face normal + error = la.norm(surf_normal.dot(face_normal_i).get(queue), np.inf) + logger.info("error[n_dot_i]: %.5e", error) + assert error < rtol + + # check angle between two neighboring elements + error = la.norm(face_normal_i.dot(face_normal_e).get(queue) + 1.0, np.inf) + logger.info("error[i_dot_e]: %.5e", error) + assert error > rtol + + # check uniqueness of normal on the two sides + error = la.norm(sum(face_normal_avg + face_normal_op).get(queue), np.inf) + logger.info("error[a_plus_o]: %.5e", error) + assert error < rtol + + # check orthogonality with face tangent + if ambient_dim == 3: + face_tangent = bind(discr, sym_face_tangent)(queue) + + error = la.norm(face_tangent.dot(face_normal_avg).get(queue), np.inf) + logger.info("error[t_dot_avg]: %.5e", error) + assert error < 5 * rtol + + # }}} + +# }}} + + +# {{{ diff operator @pytest.mark.parametrize("dim", [1, 2, 3]) def test_tri_diff_mat(ctx_factory, dim, order=4): @@ -354,6 +481,10 @@ def test_tri_diff_mat(ctx_factory, dim, order=4): logger.info("axis %d\n%s", axis, eoc_rec) assert eoc_rec.order_estimate() >= order +# }}} + + +# {{{ divergence theorem def test_2d_gauss_theorem(ctx_factory): """Verify Gauss's theorem explicitly on a mesh""" @@ -396,6 +527,10 @@ def test_2d_gauss_theorem(ctx_factory): assert abs(gauss_err) < 1e-13 +# }}} + + +# {{{ models: advection @pytest.mark.parametrize(("mesh_name", "mesh_pars"), [ ("segment", [8, 16, 32]), @@ -532,6 +667,10 @@ def test_convergence_advec(ctx_factory, mesh_name, mesh_pars, op_type, flux_type assert eoc_rec.order_estimate() > order +# }}} + + +# {{{ models: maxwell @pytest.mark.parametrize("order", [3, 4, 5]) def test_convergence_maxwell(ctx_factory, order): @@ -602,6 +741,10 @@ def test_convergence_maxwell(ctx_factory, order): assert eoc_rec.order_estimate() > order +# }}} + + +# {{{ models: variable coefficient advection oversampling @pytest.mark.parametrize("order", [2, 3, 4]) def test_improvement_quadrature(ctx_factory, order): @@ -671,6 +814,10 @@ def test_improvement_quadrature(ctx_factory, order): assert (q_errs < errs).all() assert q_eoc > order +# }}} + + +# {{{ foreign points def test_foreign_points(ctx_factory): pytest.importorskip("sumpy") @@ -687,6 +834,10 @@ def test_foreign_points(ctx_factory): bind(pdiscr, sym.nodes(dim)**2)(queue) +# }}} + + +# {{{ operator collector determinism def test_op_collector_order_determinism(): class TestOperator(sym.Operator): @@ -712,6 +863,10 @@ def test_op_collector_order_determinism(): # The output order isn't significant, but it should always be the same. assert list(TestBoundOperatorCollector(TestOperator)(ob0 + ob1)) == [ob0, ob1] +# }}} + + +# {{{ bessel def test_bessel(ctx_factory): cl_ctx = ctx_factory() @@ -741,6 +896,10 @@ def test_bessel(ctx_factory): assert z < 1e-15 +# }}} + + +# {{{ function symbol def test_external_call(ctx_factory): cl_ctx = ctx_factory() @@ -805,6 +964,8 @@ def test_function_symbol_array(ctx_factory, array_type): norm = bind(discr, sym.norm(2, sym_x))(queue, x=x) assert isinstance(norm, float) +# }}} + # You can test individual routines by typing # $ python test_grudge.py 'test_routine()'